diff --git a/.componentsignore b/.componentsignore index fc0c67764..ad294b580 100644 --- a/.componentsignore +++ b/.componentsignore @@ -25,6 +25,7 @@ "interactionPolicy.DefaultPolicy", "NodeJS.Dict", "NotificationChannelType", + "Omit", "PermissionMap", "Promise", "Readable", diff --git a/.eslintrc.js b/.eslintrc.js index d7b2b43ba..1aa63b6d1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -46,6 +46,7 @@ module.exports = { '@typescript-eslint/no-invalid-void-type': 'off', // Problems with optional parameters '@typescript-eslint/no-unnecessary-condition': 'off', + "@typescript-eslint/no-unused-vars": [ "error", { "ignoreRestSiblings": true } ], '@typescript-eslint/prefer-optional-chain': 'error', '@typescript-eslint/promise-function-async': [ 'error', { checkArrowFunctions: false } ], '@typescript-eslint/space-before-function-paren': [ 'error', 'never' ], diff --git a/config/identity/handler/adapter-factory/webid.json b/config/identity/handler/adapter-factory/webid.json index b0388b8f0..f5e8e4a04 100644 --- a/config/identity/handler/adapter-factory/webid.json +++ b/config/identity/handler/adapter-factory/webid.json @@ -5,7 +5,7 @@ "comment": "An adapter is responsible for storing all interaction metadata.", "@id": "urn:solid-server:default:IdpAdapterFactory", "@type": "ClientCredentialsAdapterFactory", - "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "webIdStore": { "@id": "urn:solid-server:default:WebIdStore" }, "clientCredentialsStore": { "@id": "urn:solid-server:default:ClientCredentialsStore" }, "source": { "@type": "WebIdAdapterFactory", diff --git a/config/identity/handler/provider-factory/identity.json b/config/identity/handler/provider-factory/identity.json index b44a46fae..0af156845 100644 --- a/config/identity/handler/provider-factory/identity.json +++ b/config/identity/handler/provider-factory/identity.json @@ -11,7 +11,7 @@ "handlers": [ { "@type": "AccountPromptFactory", - "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "webIdStore": { "@id": "urn:solid-server:default:WebIdStore" }, "cookieStore": { "@id": "urn:solid-server:default:CookieStore" }, "cookieName": { "@id": "urn:solid-server:default:value:accountCookieName" } } diff --git a/config/identity/handler/storage/default.json b/config/identity/handler/storage/default.json index 26d4b2e5f..1e7873600 100644 --- a/config/identity/handler/storage/default.json +++ b/config/identity/handler/storage/default.json @@ -2,36 +2,34 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", "@graph": [ { - "@id": "urn:solid-server:default:AccountStore", - "@type": "BaseAccountStore", + "@id": "urn:solid-server:default:AccountStorage", + "@type": "BaseLoginAccountStorage", "storage": { - "@id": "urn:solid-server:default:AccountStorage", - "@type": "WrappedExpiringStorage", - "source": { - "@type": "Base64EncodingStorage", - "source": { - "@type": "ContainerPathStorage", - "relativePath": "/accounts/data/", - "source": { "@id": "urn:solid-server:default:KeyValueStorage" } - } + "@id": "urn:solid-server:default:IndexedStorage", + "@type": "WrappedIndexedStorage", + "valueStorage": { + "@type": "ContainerPathStorage", + "relativePath": "/accounts/data/", + "source": { "@id": "urn:solid-server:default:KeyValueStorage" } + }, + "indexStorage": { + "@type": "ContainerPathStorage", + "relativePath": "/accounts/index/", + "source": { "@id": "urn:solid-server:default:KeyValueStorage" } } } }, + { + "@id": "urn:solid-server:default:AccountStore", + "@type": "BaseAccountStore", + "storage": { "@id": "urn:solid-server:default:AccountStorage" } + }, + { "@id": "urn:solid-server:default:WebIdStore", "@type": "BaseWebIdStore", - "webIdRoute": { "@id": "urn:solid-server:default:AccountWebIdLinkRoute" }, - "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, - "storage": { - "@id": "urn:solid-server:default:WebIdStorage", - "@type": "Base64EncodingStorage", - "source": { - "@type": "ContainerPathStorage", - "relativePath": "/accounts/webIds/", - "source": { "@id": "urn:solid-server:default:KeyValueStorage" } - } - } + "storage": { "@id": "urn:solid-server:default:AccountStorage" } }, { @@ -54,25 +52,37 @@ { "@id": "urn:solid-server:default:PodStore", "@type": "BasePodStore", - "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, - "podRoute": { "@id": "urn:solid-server:default:AccountPodIdRoute" }, + "storage": { "@id": "urn:solid-server:default:AccountStorage" }, "manager": { "@id": "urn:solid-server:default:PodManager" } }, { "@id": "urn:solid-server:default:ClientCredentialsStore", "@type": "BaseClientCredentialsStore", - "clientCredentialsRoute": { "@id": "urn:solid-server:default:AccountClientCredentialsIdRoute" }, - "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, - "storage": { - "@id": "urn:solid-server:default:ClientCredentialsStorage", - "@type": "Base64EncodingStorage", - "source": { - "@type": "ContainerPathStorage", - "relativePath": "/accounts/client-credentials/", - "source": { "@id": "urn:solid-server:default:KeyValueStorage" } - } - } + "storage": { "@id": "urn:solid-server:default:AccountStorage" } + }, + + { + "comment": "Initialize all the stores. Also necessary on primary thread for pod seeding.", + "@id": "urn:solid-server:default:PrimaryParallelInitializer", + "@type": "ParallelHandler", + "handlers": [ + { "@id": "urn:solid-server:default:AccountStore" }, + { "@id": "urn:solid-server:default:ClientCredentialsStore" }, + { "@id": "urn:solid-server:default:PodStore" }, + { "@id": "urn:solid-server:default:WebIdStore" } + ] + }, + { + "comment": "Initialize all the stores.", + "@id": "urn:solid-server:default:WorkerParallelInitializer", + "@type": "ParallelHandler", + "handlers": [ + { "@id": "urn:solid-server:default:AccountStore" }, + { "@id": "urn:solid-server:default:ClientCredentialsStore" }, + { "@id": "urn:solid-server:default:PodStore" }, + { "@id": "urn:solid-server:default:WebIdStore" } + ] } ] } diff --git a/config/identity/handler/storage/password.json b/config/identity/handler/storage/password.json index 461a3dc79..bc3e5449b 100644 --- a/config/identity/handler/storage/password.json +++ b/config/identity/handler/storage/password.json @@ -4,15 +4,23 @@ { "@id": "urn:solid-server:default:PasswordStore", "@type": "BasePasswordStore", - "storage": { - "@id": "urn:solid-server:default:PasswordStorage", - "@type": "Base64EncodingStorage", - "source": { - "@type": "ContainerPathStorage", - "relativePath": "/accounts/logins/password/", - "source": { "@id": "urn:solid-server:default:KeyValueStorage" } - } - } + "storage": { "@id": "urn:solid-server:default:AccountStorage" } + }, + { + "comment": "Initialize the password store. Also necessary on primary thread for pod seeding.", + "@id": "urn:solid-server:default:PrimaryParallelInitializer", + "@type": "ParallelHandler", + "handlers": [ + { "@id": "urn:solid-server:default:PasswordStore" } + ] + }, + { + "comment": "Initialize the password store.", + "@id": "urn:solid-server:default:WorkerParallelInitializer", + "@type": "ParallelHandler", + "handlers": [ + { "@id": "urn:solid-server:default:PasswordStore" } + ] }, { @@ -26,7 +34,7 @@ "source": { "@type": "ContainerPathStorage", "relativePath": "/accounts/logins/password/forgot/", - "source": { "@id": "urn:solid-server:default:KeyValueStorage" } + "source": { "@id": "urn:solid-server:default:KeyValueStorage" } } } } diff --git a/config/identity/interaction/routing/account/create.json b/config/identity/interaction/routing/account/create.json index 5b6515dac..0b69a8a72 100644 --- a/config/identity/interaction/routing/account/create.json +++ b/config/identity/interaction/routing/account/create.json @@ -17,8 +17,7 @@ "@id": "urn:solid-server:default:CreateAccountHandler", "@type": "CreateAccountHandler", "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, - "cookieStore": { "@id": "urn:solid-server:default:CookieStore" }, - "accountRoute": { "@id": "urn:solid-server:default:AccountIdRoute" } + "cookieStore": { "@id": "urn:solid-server:default:CookieStore" } } } }, diff --git a/config/identity/interaction/routing/account/resource.json b/config/identity/interaction/routing/account/resource.json index 3121b5c0d..7115f02a7 100644 --- a/config/identity/interaction/routing/account/resource.json +++ b/config/identity/interaction/routing/account/resource.json @@ -2,38 +2,10 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", "@graph": [ { - "comment": "Handles the account details.", - "@id": "urn:solid-server:default:AccountIdRouter", - "@type": "AuthorizedRouteHandler", - "route": { - "@id": "urn:solid-server:default:AccountIdRoute", - "@type": "BaseAccountIdRoute", - "base": { "@id": "urn:solid-server:default:AccountRoute" } - }, - "source": { - "@id": "urn:solid-server:default:AccountResourceHandler", - "@type": "MethodFilterHandler", - "methods": [ "GET" ], - "source": { - "@type": "AccountDetailsHandler", - "accountStore": { "@id": "urn:solid-server:default:AccountStore" } - } - } - }, - - { - "@id": "urn:solid-server:default:InteractionRouteHandler", - "@type": "WaterfallHandler", - "handlers": [{ "@id": "urn:solid-server:default:AccountIdRouter" }] - }, - - { - "@id": "urn:solid-server:default:AccountControlHandler", - "@type": "ControlHandler", - "controls": [{ - "ControlHandler:_controls_key": "account", - "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:AccountIdRoute" } - }] + "comment": "Route pointing to the account resource", + "@id": "urn:solid-server:default:AccountIdRoute", + "@type": "BaseAccountIdRoute", + "base": { "@id": "urn:solid-server:default:AccountRoute" } }, { diff --git a/config/identity/interaction/routing/client-credentials/create.json b/config/identity/interaction/routing/client-credentials/create.json index a41a40717..1b5d3ea31 100644 --- a/config/identity/interaction/routing/client-credentials/create.json +++ b/config/identity/interaction/routing/client-credentials/create.json @@ -16,8 +16,9 @@ "source": { "@id": "urn:solid-server:default:CreateClientCredentialsHandler", "@type": "CreateClientCredentialsHandler", - "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, - "clientCredentialsStore": { "@id": "urn:solid-server:default:ClientCredentialsStore" } + "webIdStore": { "@id": "urn:solid-server:default:WebIdStore" }, + "clientCredentialsStore": { "@id": "urn:solid-server:default:ClientCredentialsStore" }, + "clientCredentialsRoute": { "@id": "urn:solid-server:default:AccountClientCredentialsIdRoute" } } } } diff --git a/config/identity/interaction/routing/client-credentials/resource.json b/config/identity/interaction/routing/client-credentials/resource.json index 14c48da16..12f25adcb 100644 --- a/config/identity/interaction/routing/client-credentials/resource.json +++ b/config/identity/interaction/routing/client-credentials/resource.json @@ -19,7 +19,7 @@ "methods": [ "GET" ], "source": { "@type": "ClientCredentialsDetailsHandler", - "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "clientCredentialsRoute": { "@id": "urn:solid-server:default:AccountClientCredentialsIdRoute" }, "clientCredentialsStore": { "@id": "urn:solid-server:default:ClientCredentialsStore" } } }, @@ -28,7 +28,7 @@ "methods": [ "DELETE" ], "source": { "@type": "DeleteClientCredentialsHandler", - "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "clientCredentialsRoute": { "@id": "urn:solid-server:default:AccountClientCredentialsIdRoute" }, "clientCredentialsStore": { "@id": "urn:solid-server:default:ClientCredentialsStore" } } } diff --git a/config/identity/interaction/routing/oidc/pick-webid.json b/config/identity/interaction/routing/oidc/pick-webid.json index 4c7e48e30..b5fc0b54c 100644 --- a/config/identity/interaction/routing/oidc/pick-webid.json +++ b/config/identity/interaction/routing/oidc/pick-webid.json @@ -16,7 +16,7 @@ "source": { "@type": "PickWebIdHandler", "@id": "urn:solid-server:default:PickWebIdHandler", - "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "webIdStore": { "@id": "urn:solid-server:default:WebIdStore" }, "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" } } } diff --git a/config/identity/interaction/routing/password/create.json b/config/identity/interaction/routing/password/create.json index ed699c45b..2a6e14a09 100644 --- a/config/identity/interaction/routing/password/create.json +++ b/config/identity/interaction/routing/password/create.json @@ -16,7 +16,6 @@ "source": { "@id": "urn:solid-server:default:CreatePasswordHandler", "@type": "CreatePasswordHandler", - "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, "passwordStore": { "@id": "urn:solid-server:default:PasswordStore" }, "passwordRoute": { "@id": "urn:solid-server:default:AccountPasswordIdRoute" } } diff --git a/config/identity/interaction/routing/password/login.json b/config/identity/interaction/routing/password/login.json index bc17f6f6d..a1a848b3b 100644 --- a/config/identity/interaction/routing/password/login.json +++ b/config/identity/interaction/routing/password/login.json @@ -18,8 +18,7 @@ "@type": "PasswordLoginHandler", "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, "passwordStore": { "@id": "urn:solid-server:default:PasswordStore" }, - "cookieStore": { "@id": "urn:solid-server:default:CookieStore" }, - "accountRoute": { "@id": "urn:solid-server:default:AccountIdRoute" } + "cookieStore": { "@id": "urn:solid-server:default:CookieStore" } } } }, diff --git a/config/identity/interaction/routing/password/resource.json b/config/identity/interaction/routing/password/resource.json index 44f41552d..9d92d840d 100644 --- a/config/identity/interaction/routing/password/resource.json +++ b/config/identity/interaction/routing/password/resource.json @@ -18,7 +18,7 @@ "@type": "ViewInteractionHandler", "source": { "@type": "UpdatePasswordHandler", - "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "passwordRoute": { "@id": "urn:solid-server:default:AccountPasswordIdRoute" }, "passwordStore": { "@id": "urn:solid-server:default:PasswordStore" } } }, @@ -27,7 +27,7 @@ "methods": [ "DELETE" ], "source": { "@type": "DeletePasswordHandler", - "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "passwordRoute": { "@id": "urn:solid-server:default:AccountPasswordIdRoute" }, "passwordStore": { "@id": "urn:solid-server:default:PasswordStore" } } } diff --git a/config/identity/interaction/routing/pod/create.json b/config/identity/interaction/routing/pod/create.json index dbf123189..5054c26e6 100644 --- a/config/identity/interaction/routing/pod/create.json +++ b/config/identity/interaction/routing/pod/create.json @@ -19,9 +19,10 @@ "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "identifierGenerator": { "@id": "urn:solid-server:default:IdentifierGenerator" }, "relativeWebIdPath": "/profile/card#me", - "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, "webIdStore": { "@id": "urn:solid-server:default:WebIdStore" }, "podStore": { "@id": "urn:solid-server:default:PodStore" }, + "webIdLinkRoute": { "@id": "urn:solid-server:default:AccountWebIdLinkRoute" }, + "podIdRoute": { "@id": "urn:solid-server:default:AccountPodIdRoute" }, "allowRoot": false } } diff --git a/config/identity/interaction/routing/webid/link.json b/config/identity/interaction/routing/webid/link.json index 58d57c3bc..1ffc88e27 100644 --- a/config/identity/interaction/routing/webid/link.json +++ b/config/identity/interaction/routing/webid/link.json @@ -19,9 +19,10 @@ "@type": "LinkWebIdHandler", "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "ownershipValidator": { "@id": "urn:solid-server:default:OwnershipValidator" }, - "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "podStore": { "@id": "urn:solid-server:default:PodStore" }, "webIdStore": { "@id": "urn:solid-server:default:WebIdStore" }, - "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" } + "webIdRoute": { "@id": "urn:solid-server:default:AccountWebIdLinkRoute" }, + "storageStrategy": { "@id": "urn:solid-server:default:StorageLocationStrategy" } } } } diff --git a/config/identity/interaction/routing/webid/resource.json b/config/identity/interaction/routing/webid/resource.json index 04a11fe73..cce377a33 100644 --- a/config/identity/interaction/routing/webid/resource.json +++ b/config/identity/interaction/routing/webid/resource.json @@ -16,7 +16,7 @@ "methods": [ "DELETE" ], "source": { "@type": "UnlinkWebIdHandler", - "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "webIdRoute": { "@id": "urn:solid-server:default:AccountWebIdLinkRoute" }, "webIdStore": { "@id": "urn:solid-server:default:WebIdStore" } } } diff --git a/config/ldp/authorization/readers/ownership.json b/config/ldp/authorization/readers/ownership.json index 445ec93be..bf4d14dd2 100644 --- a/config/ldp/authorization/readers/ownership.json +++ b/config/ldp/authorization/readers/ownership.json @@ -5,9 +5,9 @@ "comment": "Allows pod owners to always edit permissions on the data.", "@id": "urn:solid-server:default:OwnerPermissionReader", "@type": "OwnerPermissionReader", + "podStore": { "@id": "urn:solid-server:default:PodStore" }, "webIdStore": { "@id": "urn:solid-server:default:WebIdStore" }, - "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, - "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" } + "storageStrategy": { "@id": "urn:solid-server:default:StorageLocationStrategy" } } ] } diff --git a/documentation/markdown/usage/account/json-api.md b/documentation/markdown/usage/account/json-api.md index 94532d303..39f79d676 100644 --- a/documentation/markdown/usage/account/json-api.md +++ b/documentation/markdown/usage/account/json-api.md @@ -73,7 +73,7 @@ All of these require authorization, except for the create action. #### controls.account.create Creates a new account on empty POST requests. -The response contains the necessary cookie values to log and a `resource` field containing the URL of the account. +The response contains the necessary cookie values to log in. This account can not be used until a login method has been added to it. All other interactions will fail until this is the case. See the [controls.password.create](#controlspasswordcreate) section below for more information on how to do this. @@ -86,11 +86,25 @@ Invalidates the cookie that was used. #### controls.account.webId +GET requests return all WebIDs linked to this account in the following format: + +```json +{ + "webIdLinks": { + "http://localhost:3000/test/profile/card#me": "http://localhost:3000/.account/account/c63c9e6f-48f8-40d0-8fec-238da893a7f2/webid/fdfc48c1-fe6f-4ce7-9e9f-1dc47eff803d/" + } +} +``` + +The URL value is the resource URL corresponding to the link with this WebID. +The link can be removed by sending a DELETE request to that URL. + POST requests link a WebID to the account, allowing the account to identify as that WebID during an OIDC authentication interaction. Expected input is an object containing a `webId` field. +The response will include the resource URL. -If the chosen WebID is contained within a Solid pod associated with this account, +If the chosen WebID is contained within a Solid pod created by this account, the request will succeed immediately. If not, an error will be thrown, asking the user to add a specific triple to the WebID to confirm that they are the owner. @@ -98,6 +112,16 @@ After this triple is added, a second request will be successful. #### controls.account.pod +GET requests return all pods created by this account in the following format: + +```json +{ + "pods": { + "http://localhost:3000/test/": "http://localhost:3000/.account/account/c63c9e6f-48f8-40d0-8fec-238da893a7f2/pod/df2d5a06-3ecd-4eaf-ac8f-b88a8579e100/" + } +} +``` + Creates a Solid pod for the account on POST requests. The only required field is `name`, which will determine the name of the pod. @@ -113,6 +137,21 @@ This WebID will then be the WebID that has initial access. #### controls.account.clientCredentials +GET requests return all client credentials created by this account in the following format: + +```json +{ + "clientCredentials": { + "token_562cdeb5-d4b2-4905-9e62-8969ac10daaa": "http://localhost:3000/.account/account/c63c9e6f-48f8-40d0-8fec-238da893a7f2/client-credentials/063ee3a7-e80f-4508-9f79-ffddda9df8d4/" + } +} +``` + +The URL value is the resource URL corresponding to that specific token. +Sending a GET request to that URL will return information about the token, +such as what the associated WebID is. +The token can be removed by sending a DELETE request to that URL. + Creates a client credentials token on POST requests. More information on these tokens can be found [here](../client-credentials.md). Expected input is an object containing a `name` and `webId` field. @@ -120,48 +159,27 @@ The name is optional and will be used to name the token, the WebID determines which WebID you will identify as when using that token. It needs to be a WebID linked to the account as described in [controls.account.webID](#controlsaccountwebid). -#### controls.account.account - -This value corresponds to the resource URL of the account you received when creating it. -This returns all resources linked to this account, such as login methods, WebIDs, pods, and client credentials tokens. - -Below is an example response object: - -```json -{ - "logins": { - "password": { - "test@example.com": "http://localhost:3000/.account/account/c63c9e6f-48f8-40d0-8fec-238da893a7f2/login/password/test%40example.com/" - } - }, - "pods": { - "http://localhost:3000/test/": "http://localhost:3000/.account/account/c63c9e6f-48f8-40d0-8fec-238da893a7f2/pod/7def7830df1161e422537db594ad2b7412ffb735e0e2320cf3e90db19cd969f9/" - }, - "webIds": { - "http://localhost:3000/test/profile/card#me": "http://localhost:3000/.account/account/c63c9e6f-48f8-40d0-8fec-238da893a7f2/webid/5c1b70d3ffaa840394dda86889ed1569cf897ef3d6041fb4c9513f82144cbb7f/" - }, - "clientCredentials": { - "token_562cdeb5-d4b2-4905-9e62-8969ac10daaa": "http://localhost:3000/.account/account/c63c9e6f-48f8-40d0-8fec-238da893a7f2/client-credentials/token_562cdeb5-d4b2-4905-9e62-8969ac10daaa/" - }, - "settings": {} -} -``` - -In each of the sub-objects, the key is always the unique identifier of whatever is being described, -while the value is the resource URL that can potentially be used to modify the resource. -Removing an entry can be done by sending a DELETE request to the resource URL, -except for pods, which cannot be deleted. -Login methods can only be deleted if the account has at least 1 login method remaining afterwards. - -The password login resource URL can also be used to modify the password, -which can be done by sending a POST request to it with the body containing an `oldPassword` and a `newPassword` field. - ### controls.password Controls related to managing the email/password login method. #### controls.password.create +GET requests return all email/password logins of this account in the following format: + +```json +{ + "passwordLogins": { + "test@example.com": "http://localhost:3000/.account/account/c63c9e6f-48f8-40d0-8fec-238da893a7f2/login/password/7f042779-e2b2-444d-8cd9-50bd9cfa516d/" + } +} +``` + +The URL value is the resource URL corresponding to the login with the given email address. +The login can be removed by sending a DELETE request to that URL. +The password can be updated by sending a POST request to that URL +with the body containing an `oldPassword` and a `newPassword` field. + POST requests create an email/password login and adds it to the account you are logged in as. Expects `email` and `password` fields. @@ -228,6 +246,7 @@ It can have an optional `remember` value, which allows for refresh tokens if it #### controls.html All these controls link to HTML pages and are thus mostly relevant to provide links to let the user navigate around. +The most important one is probably `controls.html.account.account` which links to an overview page for the account. ## Example @@ -244,8 +263,7 @@ Below is an example of a controls object in a response. "logout": "http://localhost:3000/.account/account/ade5c046-e882-4b56-80f4-18cb16433360/logout/", "webId": "http://localhost:3000/.account/account/ade5c046-e882-4b56-80f4-18cb16433360/webid/", "pod": "http://localhost:3000/.account/account/ade5c046-e882-4b56-80f4-18cb16433360/pod/", - "clientCredentials": "http://localhost:3000/.account/account/ade5c046-e882-4b56-80f4-18cb16433360/client-credentials/", - "account": "http://localhost:3000/.account/account/ade5c046-e882-4b56-80f4-18cb16433360/" + "clientCredentials": "http://localhost:3000/.account/account/ade5c046-e882-4b56-80f4-18cb16433360/client-credentials/" }, "password": { "create": "http://localhost:3000/.account/account/ade5c046-e882-4b56-80f4-18cb16433360/login/password/", diff --git a/documentation/markdown/usage/account/migration.md b/documentation/markdown/usage/account/migration.md index 123994170..7b91b1e0e 100644 --- a/documentation/markdown/usage/account/migration.md +++ b/documentation/markdown/usage/account/migration.md @@ -1,60 +1,37 @@ # Migrating account data from v6 to v7 Below is a description of the changes that are necessary to migration account data from v6 to v7 of the server. -Note that the resource identifier values are bas64 encoded before being appended to the storage location. -* "Forgot password" records - * **Storage location** - * Old: `.internal/forgot-password/` - * New: `.internal/accounts/login/password/forgot/` - * **Resource identifiers** - * Old: `"forgot-password-resource-identifier/" + recordId` - * New: `recordId` - * **Data format** - * Old: `{ recordId, email }` - * New: `email` - * **Notes** - * Just deleting all existing records is an acceptable solution as these do not contain important information. -* Client credentials tokens - * **Storage location** - * Old: `.internal/accounts/credentials/` - * New: `.internal/accounts/client-credentials/` - * **Resource identifiers** - * No change - * **Data format** - * Old: `{ webId, secret }` - * New: `{ accountId, webId, secret }` - * **Notes** - * Account IDs will need to be generated first before these can be transferred. -* Account and password data - * **Storage location** - * Old: `.internal/accounts/` - * New: Split up over the following: - * `.internal/accounts/data/` - * `.internal/accounts/webIds/` - * `.internal/accounts/logins/password/` - * **Resource identifiers** - * Old: `"account/" + encodeURIComponent(email)` or `webId` - * New: - * `.internal/accounts/data/`: Newly generated account ID. - * `.internal/accounts/webIds/`: `webID` - * `.internal/accounts/logins/password/`: `encodeURIComponent(email.toLowerCase())` - * **Data format** - * Old: `{ webId, email, password, verified }` or `{ useIdp, podBaseUrl?, clientCredentials? }` - * New: - * `.internal/accounts/data/`: `{ id, logins: { password }, pods, webIds, clientCredentials }` - * `.internal/accounts/webIds/`: `accountId[]` - * `.internal/accounts/logins/password/`: `{ accountId, password, verified }` - * **Notes** - * First account IDs need to be generated, - then login/pod/webId/clientCredentials resources need to be generated, - and then the account needs to be updated with those resources. - * Resource URLs are generated as follows: - * Passwords: `/.account/account//login/password/` - * Pods: `/.account/account//pod/` - * WebIds: `/.account/account//webid/` - * Client Credentials: `/.account/account//client-credentials/` - * The above URLs are the values in all the account objects, - the keys are the corresponding (lowercase) email, pod base URL, webID, and token name. - * Only WebIDs where `useIdp` is `true` need to be linked to the account. - * In the previous version, a WebID will be linked to exactly 1 account. +The format of the "Forgot passwords records was changed", +but seeing as those are not important and new ones can be created if necessary, +these can just be removed when migrating. +By default, these were located in the `.internal/forgot-password/` folder so this entire folder can be removed. + +For existing accounts, the data was stored in the following format and location. +Additionally to the details below, the tail of all resource identifiers were base64 encoded. + +* **Account data** + * Storage location: `.internal/accounts/` + * Resource identifiers: `"account/" + encodeURIComponent(email)` + * Data format: `{ webId, email, password, verified }` +* **Account settings** + * Storage location: `.internal/accounts/`, so same location as the account data + * Resource identifiers: `webId` + * Data format: `{ useIdp, podBaseUrl?, clientCredentials? }` + * `useIdp` indicates if the WebID is linked to the account for identification. + * `podBaseUrl` is defined if the account was created with a pod. + * `clientCredentials` is an array containing the labels of all client credentials tokens created by the account. +* **Client credentials tokens** + * Storage location: `.internal/accounts/credentials/` + * Resource identifiers: the token label + * Data format: `{ webId, secret }` + +The best way to migrate the data would be to read in the old data, +and make use of the new classes to generate the new account objects, +as generating the data manually might be too cumbersome. +Ideally the account classes of the previous version can be reused to read in the older data +to prevent having to read the old data directly. + +During migration, WebID ownership validation would need to be disabled +as otherwise the server won't allow linking the WebIDs. +The password values can be reused as the password storage method was not changed. diff --git a/documentation/markdown/usage/client-credentials.md b/documentation/markdown/usage/client-credentials.md index 7889e85fc..ac8bafbe7 100644 --- a/documentation/markdown/usage/client-credentials.md +++ b/documentation/markdown/usage/client-credentials.md @@ -18,32 +18,37 @@ so this should all be contained in an `async` function. ## Generating a token +A token can be created either on your account page, by default `http://localhost:3000/.account/`, +or by calling the relevant [API](account/json-api.md#controlsaccountclientcredentials). + +Below is an example of how to call the API to generate such a token. + The code below generates a token linked to your account and WebID. This only needs to be done once, afterwards this token can be used for all future requests. ```ts -import fetch from 'node-fetch'; - // This assumes your server is started under http://localhost:3000/. -// This URL can also be found by checking the controls in JSON responses when interacting with the IDP API, -// as described in the Identity Provider section. -const response = await fetch('http://localhost:3000/idp/credentials/', { +// It also assumes you have already logged in and `cookie` contains a valid cookie header +// as described in the API documentation. +const indexResponse = await fetch('http://localhost:3000/.account/', { headers: { cookie }}); +const { controls } = await indexResponse.json(); +const res = await fetch(controls.account.clientCredentials, { method: 'POST', - headers: { 'content-type': 'application/json' }, - // The email/password fields are those of your account. + headers: { cookie, 'content-type': 'application/json' }, // The name field will be used when generating the ID of your token. - body: JSON.stringify({ email: 'my-email@example.com', password: 'my-account-password', name: 'my-token' }), + // The WebID field determines which WebID you will identify as when using the token. + // Only WebIDs linked to your account can be used. + body: JSON.stringify({ name: 'my-token', webId: 'http://localhost:3000/my-pod/card#me' }), }); // These are the identifier and secret of your token. // Store the secret somewhere safe as there is no way to request it again from the server! -const { id, secret } = await response.json(); +// The `resource` value can be used to delete the token at a later point in time. +const { id, secret, resource } = await response.json(); ``` -If there is something wrong with your input the response code will be 500. -If no account is linked to the email, -the message will be "Account does not exist" and -if the password is wrong it will be "Incorrect password". +In case something goes wrong the status code will be 400/500 +and the response body will contain a description of the problem. ## Requesting an Access token @@ -98,11 +103,12 @@ const authFetch = await buildAuthenticatedFetch(fetch, accessToken, { dpopKey }) const response = await authFetch('http://localhost:3000/private'); ``` -## Deleting a token +## Other token actions -You can see all your existing tokens by doing a POST to `http://localhost:3000/idp/credentials/` -with as body a JSON object containing your email and password. -The response will be a JSON list containing all your tokens. +You can see all your existing tokens on your account page +or by doing a GET request to the same API to create a new token. +The details of a token can be seen by doing a GET request to the resource URL of the token. -Deleting a token requires also doing a POST to the same URL, -but adding a `delete` key to the JSON input object with as value the ID of the token you want to remove. +A token can be deleted by doing a DELETE request to the resource URL of the token. + +All of these actions require you to be logged in to the account. diff --git a/src/authorization/OwnerPermissionReader.ts b/src/authorization/OwnerPermissionReader.ts index 7a2cd35d8..1a2c88fc2 100644 --- a/src/authorization/OwnerPermissionReader.ts +++ b/src/authorization/OwnerPermissionReader.ts @@ -1,12 +1,9 @@ -import type { Credentials } from '../authentication/Credentials'; import type { AuxiliaryIdentifierStrategy } from '../http/auxiliary/AuxiliaryIdentifierStrategy'; import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; -import type { AccountStore } from '../identity/interaction/account/util/AccountStore'; +import type { PodStore } from '../identity/interaction/pod/util/PodStore'; import type { WebIdStore } from '../identity/interaction/webid/util/WebIdStore'; import { getLoggerFor } from '../logging/LogUtil'; -import { createErrorMessage } from '../util/errors/ErrorUtil'; -import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'; -import type { IdentifierStrategy } from '../util/identifiers/IdentifierStrategy'; +import type { StorageLocationStrategy } from '../server/description/StorageLocationStrategy'; import { filter } from '../util/IterableUtil'; import { IdentifierMap } from '../util/map/IdentifierMap'; import type { PermissionReaderInput } from './PermissionReader'; @@ -20,18 +17,18 @@ import type { PermissionMap } from './permissions/Permissions'; export class OwnerPermissionReader extends PermissionReader { protected readonly logger = getLoggerFor(this); + private readonly podStore: PodStore; private readonly webIdStore: WebIdStore; - private readonly accountStore: AccountStore; private readonly authStrategy: AuxiliaryIdentifierStrategy; - private readonly identifierStrategy: IdentifierStrategy; + private readonly storageStrategy: StorageLocationStrategy; - public constructor(webIdStore: WebIdStore, accountStore: AccountStore, authStrategy: AuxiliaryIdentifierStrategy, - identifierStrategy: IdentifierStrategy) { + public constructor(podStore: PodStore, webIdStore: WebIdStore, authStrategy: AuxiliaryIdentifierStrategy, + storageStrategy: StorageLocationStrategy) { super(); + this.podStore = podStore; this.webIdStore = webIdStore; - this.accountStore = accountStore; this.authStrategy = authStrategy; - this.identifierStrategy = identifierStrategy; + this.storageStrategy = storageStrategy; } public async handle(input: PermissionReaderInput): Promise { @@ -43,16 +40,21 @@ export class OwnerPermissionReader extends PermissionReader { return result; } - let podBaseUrls: ResourceIdentifier[]; - try { - podBaseUrls = await this.findPodBaseUrls(input.credentials); - } catch (error: unknown) { - this.logger.debug(`No pod owner Control permissions: ${createErrorMessage(error)}`); + const webId = input.credentials.agent?.webId; + if (!webId) { + this.logger.debug(`No WebId found for an ownership check on the pod.`); return result; } + const pods = await this.findPods(auths); + const owners = await this.findOwners(Object.values(pods)); + for (const auth of auths) { - if (podBaseUrls.some((podBaseUrl): boolean => this.identifierStrategy.contains(podBaseUrl, auth, true))) { + const webIds = owners[pods[auth.path]]; + if (!webIds) { + continue; + } + if (webIds.includes(webId)) { this.logger.debug(`Granting Control permissions to owner on ${auth.path}`); result.set(auth, { read: true, @@ -68,29 +70,40 @@ export class OwnerPermissionReader extends PermissionReader { } /** - * Find the base URL of the pod the given credentials own. - * Will throw an error if none can be found. + * Finds all pods that contain the given identifiers. + * Return value is a record where the keys are the identifiers and the values the associated pod. */ - private async findPodBaseUrls(credentials: Credentials): Promise { - if (!credentials.agent?.webId) { - throw new NotImplementedHttpError('Only authenticated agents could be owners'); - } - - const accountIds = await this.webIdStore.get(credentials.agent.webId); - if (accountIds.length === 0) { - throw new NotImplementedHttpError('No account is linked to this WebID'); - } - - const baseUrls: ResourceIdentifier[] = []; - for (const accountId of accountIds) { - const account = await this.accountStore.get(accountId); - if (!account) { - this.logger.error(`Found invalid account ID ${accountId} through WebID ${credentials.agent.webId}`); + protected async findPods(identifiers: ResourceIdentifier[]): Promise> { + const pods: Record = {}; + for (const identifier of identifiers) { + let pod: ResourceIdentifier; + try { + pod = await this.storageStrategy.getStorageIdentifier(identifier); + } catch { + this.logger.error(`Unable to find root storage for ${identifier.path}`); continue; } - baseUrls.push(...Object.keys(account.pods).map((pod): ResourceIdentifier => ({ path: pod }))); + pods[identifier.path] = pod.path; } + return pods; + } - return baseUrls; + /** + * Finds the owners of the given pods. + * Return value is a record where the keys are the pods and the values are all the WebIDs that own this pod. + */ + protected async findOwners(pods: string[]): Promise> { + const owners: Record = {}; + // Set to only have the unique values + for (const pod of new Set(pods)) { + const owner = await this.podStore.findAccount(pod); + if (!owner) { + this.logger.error(`Unable to find owner for ${pod}`); + continue; + } + + owners[pod] = (await this.webIdStore.findLinks(owner)).map((link): string => link.webId); + } + return owners; } } diff --git a/src/identity/configuration/AccountPromptFactory.ts b/src/identity/configuration/AccountPromptFactory.ts index 22eb7dc77..c3e903eae 100644 --- a/src/identity/configuration/AccountPromptFactory.ts +++ b/src/identity/configuration/AccountPromptFactory.ts @@ -2,9 +2,9 @@ import type { interactionPolicy, KoaContextWithOIDC } from '../../../templates/t import { getLoggerFor } from '../../logging/LogUtil'; import { InternalServerError } from '../../util/errors/InternalServerError'; import { importOidcProvider } from '../IdentityUtil'; -import type { AccountStore } from '../interaction/account/util/AccountStore'; import type { CookieStore } from '../interaction/account/util/CookieStore'; import { ACCOUNT_PROMPT } from '../interaction/InteractionUtil'; +import type { WebIdStore } from '../interaction/webid/util/WebIdStore'; import { PromptFactory } from './PromptFactory'; type OIDCContext = NonNullable; @@ -20,13 +20,13 @@ type ExtendedContext = OIDCContext & { internalAccountId?: string }; export class AccountPromptFactory extends PromptFactory { protected readonly logger = getLoggerFor(this); - private readonly accountStore: AccountStore; + private readonly webIdStore: WebIdStore; private readonly cookieStore: CookieStore; private readonly cookieName: string; - public constructor(accountStore: AccountStore, cookieStore: CookieStore, cookieName: string) { + public constructor(webIdStore: WebIdStore, cookieStore: CookieStore, cookieName: string) { super(); - this.accountStore = accountStore; + this.webIdStore = webIdStore; this.cookieStore = cookieStore; this.cookieName = cookieName; } @@ -60,7 +60,8 @@ export class AccountPromptFactory extends PromptFactory { const check = new ip.Check('no_webid_ownserhip', 'The stored WebID does not belong to the account.', async(ctx): Promise => { - if (!ctx.oidc.session?.accountId) { + const webId = ctx.oidc.session?.accountId; + if (!webId) { return false; } @@ -70,17 +71,11 @@ export class AccountPromptFactory extends PromptFactory { return false; } - const account = await this.accountStore.get(accountId); - if (!account) { - this.logger.error(`Invalid account ID ${accountId}`); - return false; - } + const isLinked = await this.webIdStore.isLinked(webId, accountId); + this.logger.debug(`Session has WebID ${webId + }, which ${isLinked ? 'belongs' : 'does not belong'} to the authenticated account`); - const owner = account.webIds[ctx.oidc.session.accountId]; - this.logger.debug(`Session has WebID ${ctx.oidc.session.accountId - }, which ${owner ? 'belongs' : 'does not belong'} to the authenticated account`); - - return !owner; + return !isLinked; }); const loginPrompt = policy.get('login'); if (!loginPrompt) { diff --git a/src/identity/configuration/IdentityProviderFactory.ts b/src/identity/configuration/IdentityProviderFactory.ts index b33504111..0799af4f9 100644 --- a/src/identity/configuration/IdentityProviderFactory.ts +++ b/src/identity/configuration/IdentityProviderFactory.ts @@ -277,10 +277,20 @@ export class IdentityProviderFactory implements ProviderFactory { // Add extra claims in case an AccessToken is being issued. // Specifically this sets the required webid and client_id claims for the access token // See https://solid.github.io/solid-oidc/#resource-access-validation - config.extraTokenClaims = async(ctx, token): Promise => - this.isAccessToken(token) ? - { webid: token.accountId } : - { webid: token.client && (await this.clientCredentialsStore.get(token.client.clientId))?.webId }; + config.extraTokenClaims = async(ctx, token): Promise => { + if (this.isAccessToken(token)) { + return { webid: token.accountId }; + } + const clientId = token.client?.clientId; + if (!clientId) { + throw new BadRequestHttpError('Missing client ID from client credentials.'); + } + const webId = (await this.clientCredentialsStore.findByLabel(clientId))?.webId; + if (!webId) { + throw new BadRequestHttpError(`Unknown client credentials token ${clientId}`); + } + return { webid: webId }; + }; config.features = { ...config.features, diff --git a/src/identity/interaction/CookieInteractionHandler.ts b/src/identity/interaction/CookieInteractionHandler.ts index 5b2c45b82..5b6390db2 100644 --- a/src/identity/interaction/CookieInteractionHandler.ts +++ b/src/identity/interaction/CookieInteractionHandler.ts @@ -1,6 +1,6 @@ import { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; import { SOLID_HTTP } from '../../util/Vocabularies'; -import { ACCOUNT_SETTINGS_REMEMBER_LOGIN } from './account/util/Account'; +import { ACCOUNT_SETTINGS_REMEMBER_LOGIN } from './account/util/AccountStore'; import type { AccountStore } from './account/util/AccountStore'; import type { CookieStore } from './account/util/CookieStore'; import type { JsonRepresentation } from './InteractionUtil'; @@ -51,8 +51,8 @@ export class CookieInteractionHandler extends JsonInteractionHandler { if (!accountId) { return output; } - const account = await this.accountStore.get(accountId); - if (!account?.settings[ACCOUNT_SETTINGS_REMEMBER_LOGIN]) { + const setting = await this.accountStore.getSetting(accountId, ACCOUNT_SETTINGS_REMEMBER_LOGIN); + if (!setting) { return output; } diff --git a/src/identity/interaction/account/AccountDetailsHandler.ts b/src/identity/interaction/account/AccountDetailsHandler.ts deleted file mode 100644 index c60493441..000000000 --- a/src/identity/interaction/account/AccountDetailsHandler.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Json, JsonRepresentation } from '../InteractionUtil'; -import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; -import { JsonInteractionHandler } from '../JsonInteractionHandler'; -import type { Account } from './util/Account'; -import type { AccountStore } from './util/AccountStore'; -import { getRequiredAccount } from './util/AccountUtil'; -import Dict = NodeJS.Dict; - -/** - * Outputs a JSON description of the account details. - */ -export class AccountDetailsHandler extends JsonInteractionHandler { - private readonly accountStore: AccountStore; - - public constructor(accountStore: AccountStore) { - super(); - this.accountStore = accountStore; - } - - public async handle({ accountId }: JsonInteractionHandlerInput): Promise> { - const account = await getRequiredAccount(this.accountStore, accountId); - - // The ID does not need to be in the JSON - const json: Dict = account; - delete json.id; - - return { json: account }; - } -} diff --git a/src/identity/interaction/account/CreateAccountHandler.ts b/src/identity/interaction/account/CreateAccountHandler.ts index e42ca9e9b..67c326761 100644 --- a/src/identity/interaction/account/CreateAccountHandler.ts +++ b/src/identity/interaction/account/CreateAccountHandler.ts @@ -3,7 +3,6 @@ import type { JsonRepresentation } from '../InteractionUtil'; import type { JsonView } from '../JsonView'; import type { LoginOutputType } from '../login/ResolveLoginHandler'; import { ResolveLoginHandler } from '../login/ResolveLoginHandler'; -import type { AccountIdRoute } from './AccountIdRoute'; import type { AccountStore } from './util/AccountStore'; import type { CookieStore } from './util/CookieStore'; @@ -11,8 +10,8 @@ import type { CookieStore } from './util/CookieStore'; * Creates new accounts using an {@link AccountStore}; */ export class CreateAccountHandler extends ResolveLoginHandler implements JsonView { - public constructor(accountStore: AccountStore, cookieStore: CookieStore, accountRoute: AccountIdRoute) { - super(accountStore, cookieStore, accountRoute); + public constructor(accountStore: AccountStore, cookieStore: CookieStore) { + super(accountStore, cookieStore); } public async getView(): Promise> { @@ -20,8 +19,8 @@ export class CreateAccountHandler extends ResolveLoginHandler implements JsonVie } public async login(): Promise> { - const account = await this.accountStore.create(); + const accountId = await this.accountStore.create(); - return { json: { accountId: account.id }}; + return { json: { accountId }}; } } diff --git a/src/identity/interaction/account/util/Account.ts b/src/identity/interaction/account/util/Account.ts deleted file mode 100644 index c001c17ae..000000000 --- a/src/identity/interaction/account/util/Account.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { Json } from '../../InteractionUtil'; -import Dict = NodeJS.Dict; - -/** - * Settings parameter used to determine if the user wants the login to be remembered. - */ -export const ACCOUNT_SETTINGS_REMEMBER_LOGIN = 'rememberLogin'; - -/** - * Object used to keep track of all the relevant account data. - * All key/value objects stored in this are expected to have the same similar structure: - * the keys should be the unique value relevant for that type of data, - * while the values should be the URL of the corresponding resource that can be used to potentially modify this entry. - */ -export type Account = { - /** - * A unique identifier for this account. - */ - readonly id: string; - /** - * All login methods that can be used to identify as this account. - * As one login method can have multiple entries, this is a nested map. - * You could have several different e-mail addresses to log in with for example. - * The keys of the first map are the unique identifiers of the login methods. - * The keys of the second map are the unique identifiers of the entry within that login method. - * - * For example, assume we have a login method `password` that uses e-mail addresses to identify entries, - * this could look as follows: - * `{ logins: { password: { ['test@example.com']: 'http://localhost:3000/.account/123/logins/password/123' } } }`. - * - * Implementations should make sure it is not possible to have an account without login method, - * as that would make the account inaccessible. - */ - readonly logins: Dict>; - /** - * The pods this account is the owner of. - * The keys are the base URLs of those pods. - */ - readonly pods: Dict; - /** - * All WebIDs registered to this account, - * meaning this account can identify as any of these WebIDs after logging in. - * The keys are the actual WebIDs. - */ - readonly webIds: Dict; - /** - * The client credentials stored for this account. - * The keys are the IDs of the tokens. - */ - readonly clientCredentials: Dict; - /** - * Various settings of the account. - * This is an open-ended object that can be used for any settings that need to be tracked on an account, - * hence there are no strict typings on the values. - */ - readonly settings: Dict; -}; diff --git a/src/identity/interaction/account/util/AccountStore.ts b/src/identity/interaction/account/util/AccountStore.ts index 16e20e20b..8c2c0ee33 100644 --- a/src/identity/interaction/account/util/AccountStore.ts +++ b/src/identity/interaction/account/util/AccountStore.ts @@ -1,28 +1,35 @@ -import type { Account } from './Account'; +/** + * Settings parameter used to determine if the user wants the login to be remembered. + */ +export const ACCOUNT_SETTINGS_REMEMBER_LOGIN = 'rememberLogin'; +export type AccountSettings = { [ACCOUNT_SETTINGS_REMEMBER_LOGIN]?: boolean }; + +/* eslint-disable @typescript-eslint/method-signature-style */ /** * Used to store account data. */ export interface AccountStore { /** - * Creates a new and completely empty account. + * Creates a new and empty account. * Since this account will not yet have a login method, * implementations should restrict what is possible with this account, * and should potentially have something in place to clean these accounts up if they are unused. */ - create: () => Promise; + create: () => Promise; + /** - * Finds the account with the given identifier. + * Finds the setting of the account with the given identifier. * @param id - The account identifier. + * @param setting - The setting to find the value of. */ - get: (id: string) => Promise; + getSetting(id: string, setting: T): Promise; + /** - * Updates the account with the given values. - * The account will be completely overwritten with the provided {@link Account} object. - * - * It should not be possible to update an account to have no login methods. - * - * @param account - The new values for the account. + * Updates the settings for the account with the given identifier to the new values. + * @param id - The account identifier. + * @param setting - The setting to update. + * @param value - The new value for the setting. */ - update: (account: Account) => Promise; + updateSetting(id: string, setting: T, value: AccountSettings[T]): Promise; } diff --git a/src/identity/interaction/account/util/AccountUtil.ts b/src/identity/interaction/account/util/AccountUtil.ts index c14448431..4ddce9cb6 100644 --- a/src/identity/interaction/account/util/AccountUtil.ts +++ b/src/identity/interaction/account/util/AccountUtil.ts @@ -1,93 +1,46 @@ import { getLoggerFor } from '../../../../logging/LogUtil'; +import { InternalServerError } from '../../../../util/errors/InternalServerError'; import { NotFoundHttpError } from '../../../../util/errors/NotFoundHttpError'; -import type { Account } from './Account'; -import type { AccountStore } from './AccountStore'; -import Dict = NodeJS.Dict; +import type { InteractionRoute } from '../../routing/InteractionRoute'; const logger = getLoggerFor('AccountUtil'); /** - * Finds the account in the store for the given `accountId`. - * Throws a {@link NotFoundHttpError} if no account is found. - * - * @param accountStore - Account store to look in. - * @param accountId - Identifier to look up. + * Asserts that the ID is defined. Throws a 404 otherwise. */ -export async function getRequiredAccount(accountStore: AccountStore, accountId?: string): Promise { - const account = accountId && await accountStore.get(accountId); - if (!account) { - logger.debug('Missing account'); +export function assertAccountId(accountId?: string): asserts accountId is string { + if (!accountId) { throw new NotFoundHttpError(); } - return account; } /** - * Looks for the key in the provided `data` object with `resource` as value. - * This was designed specifically for working with {@link Account} data where you have a resource - * but don't know which key it is associated with. + * Parses the given path with the given {@link InteractionRoute}. + * This assumes this call will succeed and thus expects the path to have the correct format. + * If not, a 500 error will be thrown. * - * @param data - Object to look in. - * @param resource - The resource URL. - * - * @throws A {@link NotFoundHttpError} if no match could be found. + * @param route - Route to parse with. + * @param path - Path to parse. */ -export function ensureResource(data?: Dict, resource?: string): string { - if (!data || !resource) { - throw new NotFoundHttpError(); +export function parsePath>(route: T, path: string): +NonNullable> { + const match = route.matchPath(path) as ReturnType | undefined; + if (!match) { + logger.error(`Unable to parse path ${path}. This usually implies a server misconfiguration.`); + throw new InternalServerError(`Unable to parse path ${path}. This usually implies a server misconfiguration.`); } - const token = Object.keys(data).find((key): boolean => data[key] === resource); - if (!token) { - logger.debug(`Missing resource ${resource}`); - throw new NotFoundHttpError(); - } - return token; + return match; } /** - * Adds a login entry for a specific login method to the account data. + * Asserts that the two given IDs are identical. + * To be used when a request tries to access a resource to ensure they're not accessing someone else's data. * - * @param account - {@link Account} to update. - * @param method - Name of the login method. - * @param key - Key of the entry. - * @param resource - Resource associated with the entry. + * @param input - Input ID. + * @param expected - Expected ID. */ -export function addLoginEntry(account: Account, method: string, key: string, resource: string): void { - const logins = account.logins[method] ?? {}; - account.logins[method] = logins; - logins[key] = resource; -} - -/** - * Updates {@link Account} data in such a way to minimize chances of incomplete updates - * when multiple storages have to be updated simultaneously. - * - * First the `accountStore` will be used to update the account, then the `operation` function will be executed. - * If that latter call fails, the updates done to the account will be reverted. - * In both success and failure, the result of calling `operation` will be returned. - * - * @param account - The account object with the new data. If the `operation` call fails, - * this object will be updated to contain the original account data. - * @param accountStore - Store used to update the account. - * @param operation - Function to execute safely. - */ -export async function safeUpdate(account: Account, accountStore: AccountStore, operation: () => Promise): -Promise { - const oldAccount = await accountStore.get(account.id); - if (!oldAccount) { +export function verifyAccountId(input?: string, expected?: string): asserts expected is string { + if (input !== expected) { throw new NotFoundHttpError(); } - - await accountStore.update(account); - try { - return await operation(); - } catch (error: unknown) { - logger.warn(`Error while updating account ${account.id}, reverting operation.`); - await accountStore.update(oldAccount); - // Update the keys of the input `account` variable to make sure it matches what is now stored again. - // This is relevant if the error thrown here is caught and the account object is still used for some reason. - Object.assign(account, oldAccount); - - throw error; - } } diff --git a/src/identity/interaction/account/util/BaseAccountStore.ts b/src/identity/interaction/account/util/BaseAccountStore.ts index ad6d6add8..a389dbc30 100644 --- a/src/identity/interaction/account/util/BaseAccountStore.ts +++ b/src/identity/interaction/account/util/BaseAccountStore.ts @@ -1,69 +1,63 @@ -import { v4 } from 'uuid'; +import { Initializer } from '../../../../init/Initializer'; import { getLoggerFor } from '../../../../logging/LogUtil'; -import type { ExpiringStorage } from '../../../../storage/keyvalue/ExpiringStorage'; -import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; -import { NotFoundHttpError } from '../../../../util/errors/NotFoundHttpError'; -import type { Account } from './Account'; -import type { AccountStore } from './AccountStore'; +import type { ValueType } from '../../../../storage/keyvalue/IndexedStorage'; +import { createErrorMessage } from '../../../../util/errors/ErrorUtil'; +import { InternalServerError } from '../../../../util/errors/InternalServerError'; +import type { AccountStore, AccountSettings } from './AccountStore'; +import { ACCOUNT_SETTINGS_REMEMBER_LOGIN } from './AccountStore'; +import type { AccountLoginStorage } from './LoginStorage'; +import { ACCOUNT_TYPE } from './LoginStorage'; + +const STORAGE_DESCRIPTION = { + [ACCOUNT_SETTINGS_REMEMBER_LOGIN]: 'boolean?', +} as const; /** - * A {@link AccountStore} that uses an {@link ExpiringStorage} to keep track of the accounts. - * Created accounts will be removed after the chosen expiration in seconds, default 30 minutes, - * if no login method gets added. - * - * New accounts can not be updated unless the update includes at least 1 login method. + * A {@link AccountStore} that uses an {@link AccountLoginStorage} to keep track of the accounts. + * Needs to be initialized before it can be used. */ -export class BaseAccountStore implements AccountStore { +export class BaseAccountStore extends Initializer implements AccountStore { private readonly logger = getLoggerFor(this); - private readonly storage: ExpiringStorage; - private readonly expiration: number; + private readonly storage: AccountLoginStorage<{ [ACCOUNT_TYPE]: typeof STORAGE_DESCRIPTION }>; + private initialized = false; - public constructor(storage: ExpiringStorage, expiration = 30 * 60) { + public constructor(storage: AccountLoginStorage) { + super(); this.storage = storage; - this.expiration = expiration * 1000; } - public async create(): Promise { - const id = v4(); - const account: Account = { - id, - logins: {}, - pods: {}, - webIds: {}, - clientCredentials: {}, - settings: {}, - }; + // Initialize the type definitions + public async handle(): Promise { + if (this.initialized) { + return; + } + try { + await this.storage.defineType(ACCOUNT_TYPE, STORAGE_DESCRIPTION, false); + this.initialized = true; + } catch (cause: unknown) { + throw new InternalServerError(`Error defining account in storage: ${createErrorMessage(cause)}`, { cause }); + } + } - // Expire accounts after some time if no login gets added - await this.storage.set(id, account, this.expiration); + public async create(): Promise { + const { id } = await this.storage.create(ACCOUNT_TYPE, {}); this.logger.debug(`Created new account ${id}`); - return account; + return id; } - public async get(id: string): Promise { - return this.storage.get(id); + public async getSetting(id: string, setting: T): Promise { + const account = await this.storage.get(ACCOUNT_TYPE, id); + if (!account) { + return; + } + const { id: unused, ...settings } = account; + return settings[setting]; } - public async update(account: Account): Promise { - const oldAccount = await this.get(account.id); - // Make sure the account exists - if (!oldAccount) { - this.logger.warn(`Trying to update account ${account.id} which does not exist`); - throw new NotFoundHttpError(); - } - - // Ensure there is at least 1 login method - const logins = Object.values(account.logins); - if (!logins.some((specificLogins): boolean => Object.keys(specificLogins ?? {}).length > 0)) { - this.logger.warn(`Trying to update account ${account.id} without login methods`); - throw new BadRequestHttpError('An account needs at least 1 login method.'); - } - - // This will disable the expiration if there still was one - await this.storage.set(account.id, account); - - this.logger.debug(`Updated account ${account.id}`); + public async updateSetting(id: string, setting: T, value: AccountSettings[T]): + Promise { + await this.storage.setField(ACCOUNT_TYPE, id, setting, value as ValueType); } } diff --git a/src/identity/interaction/account/util/BaseLoginAccountStorage.ts b/src/identity/interaction/account/util/BaseLoginAccountStorage.ts new file mode 100644 index 000000000..4ab27fa3e --- /dev/null +++ b/src/identity/interaction/account/util/BaseLoginAccountStorage.ts @@ -0,0 +1,190 @@ +import { getLoggerFor } from '../../../../logging/LogUtil'; +import type { CreateTypeObject, + TypeObject, + StringKey, + IndexedStorage, + IndexTypeCollection, IndexedQuery, ValueType } from '../../../../storage/keyvalue/IndexedStorage'; +import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; +import { NotFoundHttpError } from '../../../../util/errors/NotFoundHttpError'; +import type { LoginStorage } from './LoginStorage'; +import { ACCOUNT_TYPE } from './LoginStorage'; + +const LOGIN_COUNT = 'linkedLoginsCount'; + +const MINIMUM_ACCOUNT_DESCRIPTION = { + [LOGIN_COUNT]: 'number', +} as const; + +/** + * A {@link LoginStorage} that wraps around another {@link IndexedStorage} to add specific account requirements. + * * New accounts will be removed after expiration time, in seconds, default is 1800, + * if no login method was added to them in that time. + * * Non-login types can not be created until the associated account has at least 1 login method. + * * Login types can not be deleted if they are the last login of the associated account. + * + * All of this is tracked by adding a new field to the account object, + * that keeps track of how many login objects are associated with the account. + */ +export class BaseLoginAccountStorage> implements LoginStorage { + private readonly logger = getLoggerFor(this); + + protected readonly loginTypes: string[]; + protected readonly storage: IndexedStorage; + private readonly expiration: number; + protected readonly accountKeys: NodeJS.Dict; + + public constructor(storage: IndexedStorage, expiration = 30 * 60) { + this.loginTypes = []; + this.storage = storage; + this.expiration = expiration * 1000; + this.accountKeys = {}; + } + + public async defineType>(type: TType, description: T[TType], isLogin: boolean): + Promise { + // Determine potential new key pointing to account ID + this.accountKeys[type] = Object.entries(description) + .find(([ , desc ]): boolean => desc === `id:${ACCOUNT_TYPE}`)?.[0]; + + if (type === ACCOUNT_TYPE) { + description = { ...description, ...MINIMUM_ACCOUNT_DESCRIPTION }; + } + + if (isLogin) { + this.loginTypes.push(type); + } + + return this.storage.defineType(type, description); + } + + public async createIndex>(type: TType, key: StringKey): Promise { + return this.storage.createIndex(type, key); + } + + public async create>(type: TType, value: CreateTypeObject): + Promise> { + // Check login count if it is not a new login method that we are trying to add, + // to make sure the account is already valid. + // If we are adding a new login method: increase the login counter by 1. + const accountKey = this.accountKeys[type]; + if (accountKey) { + const accountId = value[accountKey] as string; + await this.checkAccount(type, accountId, true); + } + + if (type === ACCOUNT_TYPE) { + value = { ...value, [LOGIN_COUNT]: 0 }; + } + + const result = await this.storage.create(type, value); + + if (type === ACCOUNT_TYPE) { + this.createAccountTimeout(result.id); + } + + return this.cleanOutput(result); + } + + public async has>(type: TType, id: string): Promise { + return this.storage.has(type, id); + } + + public async get>(type: TType, id: string): Promise | undefined> { + return this.cleanOutput(await this.storage.get(type, id)); + } + + public async find>(type: TType, query: IndexedQuery): + Promise[]> { + return (await this.storage.find(type, query)).map(this.cleanOutput); + } + + public async findIds>(type: TType, query: IndexedQuery): Promise { + return await this.storage.findIds(type, query); + } + + public async set>(type: TType, value: TypeObject): Promise { + if (type === ACCOUNT_TYPE) { + // Get login count from original object + const original = await this.storage.get(type, value.id); + if (!original) { + throw new NotFoundHttpError(); + } + // This makes sure we don't lose the login count + value = { ...value, [LOGIN_COUNT]: original[LOGIN_COUNT] }; + } + + return this.storage.set(type, value); + } + + public async setField, TKey extends StringKey>(type: TType, id: string, + key: TKey, value: ValueType): Promise { + return this.storage.setField(type, id, key, value); + } + + public async delete>(type: TType, id: string): Promise { + const accountKey = this.accountKeys[type]; + if (accountKey && this.loginTypes.includes(type)) { + const original = await this.storage.get(type, id); + if (!original) { + throw new NotFoundHttpError(); + } + const accountId = original[accountKey] as string; + await this.checkAccount(type, accountId, false); + } + return this.storage.delete(type, id); + } + + public async* entries>(type: TType): AsyncIterableIterator> { + for await (const entry of this.storage.entries(type)) { + yield this.cleanOutput(entry); + } + } + + /** + * Creates a timer that removes the account with the given ID if + * it doesn't have a login method when the timer runs out. + */ + protected createAccountTimeout(id: string): void { + const timer = setTimeout(async(): Promise => { + const account = await this.storage.get(ACCOUNT_TYPE, id); + if (account && account[LOGIN_COUNT] === 0) { + this.logger.debug(`Removing account with no login methods ${id}`); + await this.storage.delete(ACCOUNT_TYPE, id); + } + }, this.expiration); + timer.unref(); + } + + /** + * Makes sure of the operation, adding or removing an object of the given type, + * is allowed, based on the current amount of login methods on the given account. + */ + protected async checkAccount(type: string, accountId: string, add: boolean): Promise { + const account = await this.storage.get(ACCOUNT_TYPE, accountId); + if (!account) { + throw new NotFoundHttpError(); + } + + if (this.loginTypes.includes(type)) { + if (!add && account[LOGIN_COUNT] === 1) { + this.logger.warn(`Trying to remove last login method from account ${accountId}`); + throw new BadRequestHttpError('An account needs at least 1 login method.'); + } + (account as TypeObject)[LOGIN_COUNT] += add ? 1 : -1; + await this.storage.set(ACCOUNT_TYPE, account); + } else if (account[LOGIN_COUNT] === 0) { + this.logger.warn(`Trying to update account ${accountId} without login methods`); + throw new BadRequestHttpError('An account needs at least 1 login method.'); + } + } + + /** + * Removes the field that keeps track of the login counts, to hide this from the output. + */ + protected cleanOutput | undefined>(value: TVal): TVal { + if (value) { + delete value[LOGIN_COUNT]; + } + return value; + } +} diff --git a/src/identity/interaction/account/util/LoginStorage.ts b/src/identity/interaction/account/util/LoginStorage.ts new file mode 100644 index 000000000..e4623c1fe --- /dev/null +++ b/src/identity/interaction/account/util/LoginStorage.ts @@ -0,0 +1,32 @@ +import type { ValueTypeDescription, + IndexedStorage, + StringKey, IndexTypeCollection } from '../../../../storage/keyvalue/IndexedStorage'; + +export const ACCOUNT_TYPE = 'account'; + +/** + * A {@link IndexedStorage} where the `defineType` function + * takes an extra parameter to indicate if the type corresponds to a login method. + * This is useful for storages that want to add extra requirements based on the data being edited. + * + * In practice, we use this because we want to require accounts to have at least 1 login method. + */ +export interface LoginStorage> extends Omit, 'defineType'> { + /** + * Defines a type in the storage, just like in an {@link IndexedStorage}, + * but additionally it needs to be indicated if the type corresponds to a login method or not. + * + * @param type - Type to define. + * @param description - Description of the type. + * @param isLogin - Whether this type corresponds to a login method or not. + */ + defineType: >(type: TType, description: T[TType], isLogin: boolean) => Promise; +} + +/** + * A {@link LoginStorage} with specific typings to ensure other types can reference account IDs + * without actually needing to specify it explicitly in their storage type. + */ +export type AccountLoginStorage>>> = + LoginStorage; diff --git a/src/identity/interaction/client-credentials/ClientCredentialsAdapterFactory.ts b/src/identity/interaction/client-credentials/ClientCredentialsAdapterFactory.ts index af06862e8..d81277030 100644 --- a/src/identity/interaction/client-credentials/ClientCredentialsAdapterFactory.ts +++ b/src/identity/interaction/client-credentials/ClientCredentialsAdapterFactory.ts @@ -2,7 +2,7 @@ import type { Adapter, AdapterPayload } from '../../../../templates/types/oidc-p import { getLoggerFor } from '../../../logging/LogUtil'; import type { AdapterFactory } from '../../storage/AdapterFactory'; import { PassthroughAdapterFactory, PassthroughAdapter } from '../../storage/PassthroughAdapterFactory'; -import type { AccountStore } from '../account/util/AccountStore'; +import type { WebIdStore } from '../webid/util/WebIdStore'; import type { ClientCredentialsStore } from './util/ClientCredentialsStore'; /** @@ -13,68 +13,64 @@ import type { ClientCredentialsStore } from './util/ClientCredentialsStore'; export class ClientCredentialsAdapter extends PassthroughAdapter { protected readonly logger = getLoggerFor(this); - private readonly accountStore: AccountStore; + private readonly webIdStore: WebIdStore; private readonly clientCredentialsStore: ClientCredentialsStore; - public constructor(name: string, source: Adapter, accountStore: AccountStore, + public constructor(name: string, source: Adapter, webIdStore: WebIdStore, clientCredentialsStore: ClientCredentialsStore) { super(name, source); - this.accountStore = accountStore; + this.webIdStore = webIdStore; this.clientCredentialsStore = clientCredentialsStore; } - public async find(id: string): Promise { - let payload = await this.source.find(id); + public async find(label: string): Promise { + let payload = await this.source.find(label); if (!payload && this.name === 'Client') { - const credentials = await this.clientCredentialsStore.get(id); - if (credentials) { - // Make sure the WebID is still linked to the account. - // Unlinking a WebID does not necessarily delete the corresponding credential tokens. - const account = await this.accountStore.get(credentials.accountId); - if (!account) { - this.logger.error(`Storage contains credentials ${id} with unknown account ID ${credentials.accountId}`); - return; - } - - if (!account.webIds[credentials.webId]) { - this.logger.warn( - `Client credentials token ${id} contains WebID that is no longer linked to the account. Removing...`, - ); - await this.clientCredentialsStore.delete(id, account); - return; - } - - this.logger.debug(`Authenticating as ${credentials.webId} using client credentials`); - - /* eslint-disable @typescript-eslint/naming-convention */ - payload = { - client_id: id, - client_secret: credentials.secret, - grant_types: [ 'client_credentials' ], - redirect_uris: [], - response_types: [], - }; - /* eslint-enable @typescript-eslint/naming-convention */ + const credentials = await this.clientCredentialsStore.findByLabel(label); + if (!credentials) { + return payload; } + + // Make sure the WebID wasn't unlinked in the meantime + const valid = await this.webIdStore.isLinked(credentials.webId, credentials.accountId); + if (!valid) { + this.logger.error( + `Client credentials token ${label} contains WebID that is no longer linked to the account. Removing...`, + ); + await this.clientCredentialsStore.delete(credentials.id); + return payload; + } + + this.logger.debug(`Authenticating as ${credentials.webId} using client credentials`); + + /* eslint-disable @typescript-eslint/naming-convention */ + payload = { + client_id: label, + client_secret: credentials.secret, + grant_types: [ 'client_credentials' ], + redirect_uris: [], + response_types: [], + }; + /* eslint-enable @typescript-eslint/naming-convention */ } return payload; } } export class ClientCredentialsAdapterFactory extends PassthroughAdapterFactory { - private readonly accountStore: AccountStore; + private readonly webIdStore: WebIdStore; private readonly clientCredentialsStore: ClientCredentialsStore; - public constructor(source: AdapterFactory, accountStore: AccountStore, + public constructor(source: AdapterFactory, webIdStore: WebIdStore, clientCredentialsStore: ClientCredentialsStore) { super(source); - this.accountStore = accountStore; + this.webIdStore = webIdStore; this.clientCredentialsStore = clientCredentialsStore; } public createStorageAdapter(name: string): Adapter { const adapter = this.source.createStorageAdapter(name); - return new ClientCredentialsAdapter(name, adapter, this.accountStore, this.clientCredentialsStore); + return new ClientCredentialsAdapter(name, adapter, this.webIdStore, this.clientCredentialsStore); } } diff --git a/src/identity/interaction/client-credentials/ClientCredentialsDetailsHandler.ts b/src/identity/interaction/client-credentials/ClientCredentialsDetailsHandler.ts index e790f883d..0bc8bcb90 100644 --- a/src/identity/interaction/client-credentials/ClientCredentialsDetailsHandler.ts +++ b/src/identity/interaction/client-credentials/ClientCredentialsDetailsHandler.ts @@ -1,10 +1,9 @@ import { getLoggerFor } from '../../../logging/LogUtil'; -import { InternalServerError } from '../../../util/errors/InternalServerError'; -import type { AccountStore } from '../account/util/AccountStore'; -import { ensureResource, getRequiredAccount } from '../account/util/AccountUtil'; +import { parsePath, verifyAccountId } from '../account/util/AccountUtil'; import type { JsonRepresentation } from '../InteractionUtil'; import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; import { JsonInteractionHandler } from '../JsonInteractionHandler'; +import type { ClientCredentialsIdRoute } from './util/ClientCredentialsIdRoute'; import type { ClientCredentialsStore } from './util/ClientCredentialsStore'; type OutType = { @@ -18,30 +17,23 @@ type OutType = { export class ClientCredentialsDetailsHandler extends JsonInteractionHandler { protected readonly logger = getLoggerFor(this); - private readonly accountStore: AccountStore; private readonly clientCredentialsStore: ClientCredentialsStore; + private readonly clientCredentialsRoute: ClientCredentialsIdRoute; - public constructor(accountStore: AccountStore, clientCredentialsStore: ClientCredentialsStore) { + public constructor(clientCredentialsStore: ClientCredentialsStore, clientCredentialsRoute: ClientCredentialsIdRoute) { super(); - this.accountStore = accountStore; this.clientCredentialsStore = clientCredentialsStore; + this.clientCredentialsRoute = clientCredentialsRoute; } public async handle({ target, accountId }: JsonInteractionHandlerInput): Promise> { - const account = await getRequiredAccount(this.accountStore, accountId); + const match = parsePath(this.clientCredentialsRoute, target.path); - const id = ensureResource(account.clientCredentials, target.path); - - const credentials = await this.clientCredentialsStore.get(id); - if (!credentials) { - this.logger.error( - `Data inconsistency between account and credentials data for account ${account.id} and token ${id}.`, - ); - throw new InternalServerError('Data inconsistency between account and client credentials data.'); - } + const credentials = await this.clientCredentialsStore.get(match.clientCredentialsId); + verifyAccountId(accountId, credentials?.accountId); return { json: { - id, + id: credentials.label, webId: credentials.webId, }}; } diff --git a/src/identity/interaction/client-credentials/CreateClientCredentialsHandler.ts b/src/identity/interaction/client-credentials/CreateClientCredentialsHandler.ts index 88ca2ec9a..d53420a1b 100644 --- a/src/identity/interaction/client-credentials/CreateClientCredentialsHandler.ts +++ b/src/identity/interaction/client-credentials/CreateClientCredentialsHandler.ts @@ -1,13 +1,16 @@ import { v4 } from 'uuid'; import { object, string } from 'yup'; +import { getLoggerFor } from '../../../logging/LogUtil'; +import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; import { sanitizeUrlPart } from '../../../util/StringUtil'; -import type { AccountStore } from '../account/util/AccountStore'; -import { getRequiredAccount } from '../account/util/AccountUtil'; +import { assertAccountId } from '../account/util/AccountUtil'; import type { JsonRepresentation } from '../InteractionUtil'; import { JsonInteractionHandler } from '../JsonInteractionHandler'; import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; import type { JsonView } from '../JsonView'; +import type { WebIdStore } from '../webid/util/WebIdStore'; import { parseSchema, validateWithError } from '../YupUtil'; +import type { ClientCredentialsIdRoute } from './util/ClientCredentialsIdRoute'; import type { ClientCredentialsStore } from './util/ClientCredentialsStore'; const inSchema = object({ @@ -25,28 +28,47 @@ type OutType = { * Handles the creation of client credential tokens. */ export class CreateClientCredentialsHandler extends JsonInteractionHandler implements JsonView { - private readonly accountStore: AccountStore; - private readonly clientCredentialsStore: ClientCredentialsStore; + protected readonly logger = getLoggerFor(this); - public constructor(accountStore: AccountStore, clientCredentialsStore: ClientCredentialsStore) { + private readonly webIdStore: WebIdStore; + private readonly clientCredentialsStore: ClientCredentialsStore; + private readonly clientCredentialsRoute: ClientCredentialsIdRoute; + + public constructor(webIdStore: WebIdStore, clientCredentialsStore: ClientCredentialsStore, + clientCredentialsRoute: ClientCredentialsIdRoute) { super(); - this.accountStore = accountStore; + this.webIdStore = webIdStore; this.clientCredentialsStore = clientCredentialsStore; + this.clientCredentialsRoute = clientCredentialsRoute; } - public async getView(): Promise { - return { json: parseSchema(inSchema) }; + public async getView({ accountId }: JsonInteractionHandlerInput): Promise { + assertAccountId(accountId); + const clientCredentials: Record = {}; + for (const { id, label } of await this.clientCredentialsStore.findByAccount(accountId)) { + clientCredentials[label] = this.clientCredentialsRoute.getPath({ accountId, clientCredentialsId: id }); + } + return { json: { ...parseSchema(inSchema), clientCredentials }}; } public async handle({ accountId, json }: JsonInteractionHandlerInput): Promise> { - const account = await getRequiredAccount(this.accountStore, accountId); + assertAccountId(accountId); const { name, webId } = await validateWithError(inSchema, json); + + if (!await this.webIdStore.isLinked(webId, accountId)) { + this.logger.warn(`Trying to create token for ${webId} which does not belong to account ${accountId}`); + throw new BadRequestHttpError('WebID does not belong to this account.'); + } + const cleanedName = name ? sanitizeUrlPart(name.trim()) : ''; - const id = `${cleanedName}_${v4()}`; + const label = `${cleanedName}_${v4()}`; - const { secret, resource } = await this.clientCredentialsStore.add(id, webId, account); + const { secret, id } = await this.clientCredentialsStore.create(label, webId, accountId); + const resource = this.clientCredentialsRoute.getPath({ accountId, clientCredentialsId: id }); - return { json: { id, secret, resource }}; + // Exposing the field as `id` as that is how we originally defined the client credentials API + // and is more consistent with how the field names are explained in other places + return { json: { id: label, secret, resource }}; } } diff --git a/src/identity/interaction/client-credentials/DeleteClientCredentialsHandler.ts b/src/identity/interaction/client-credentials/DeleteClientCredentialsHandler.ts index d001d2cc4..7087d3c0c 100644 --- a/src/identity/interaction/client-credentials/DeleteClientCredentialsHandler.ts +++ b/src/identity/interaction/client-credentials/DeleteClientCredentialsHandler.ts @@ -1,31 +1,31 @@ import type { EmptyObject } from '../../../util/map/MapUtil'; -import type { AccountStore } from '../account/util/AccountStore'; -import { ensureResource, getRequiredAccount } from '../account/util/AccountUtil'; +import { parsePath, verifyAccountId } from '../account/util/AccountUtil'; import type { JsonRepresentation } from '../InteractionUtil'; import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; import { JsonInteractionHandler } from '../JsonInteractionHandler'; +import type { ClientCredentialsIdRoute } from './util/ClientCredentialsIdRoute'; import type { ClientCredentialsStore } from './util/ClientCredentialsStore'; /** * Handles the deletion of client credentials tokens. */ export class DeleteClientCredentialsHandler extends JsonInteractionHandler { - private readonly accountStore: AccountStore; private readonly clientCredentialsStore: ClientCredentialsStore; + private readonly clientCredentialsRoute: ClientCredentialsIdRoute; - public constructor(accountStore: AccountStore, clientCredentialsStore: ClientCredentialsStore) { + public constructor(clientCredentialsStore: ClientCredentialsStore, clientCredentialsRoute: ClientCredentialsIdRoute) { super(); - this.accountStore = accountStore; this.clientCredentialsStore = clientCredentialsStore; + this.clientCredentialsRoute = clientCredentialsRoute; } public async handle({ target, accountId }: JsonInteractionHandlerInput): Promise> { - const account = await getRequiredAccount(this.accountStore, accountId); + const match = parsePath(this.clientCredentialsRoute, target.path); - const id = ensureResource(account.clientCredentials, target.path); + const credentials = await this.clientCredentialsStore.get(match.clientCredentialsId); + verifyAccountId(accountId, credentials?.accountId); - // This also deletes it from the account - await this.clientCredentialsStore.delete(id, account); + await this.clientCredentialsStore.delete(match.clientCredentialsId); return { json: {}}; } diff --git a/src/identity/interaction/client-credentials/util/BaseClientCredentialsStore.ts b/src/identity/interaction/client-credentials/util/BaseClientCredentialsStore.ts index 120708684..0469a1886 100644 --- a/src/identity/interaction/client-credentials/util/BaseClientCredentialsStore.ts +++ b/src/identity/interaction/client-credentials/util/BaseClientCredentialsStore.ts @@ -1,63 +1,79 @@ import { randomBytes } from 'crypto'; +import { Initializer } from '../../../../init/Initializer'; import { getLoggerFor } from '../../../../logging/LogUtil'; -import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage'; -import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; -import type { Account } from '../../account/util/Account'; -import type { AccountStore } from '../../account/util/AccountStore'; -import { safeUpdate } from '../../account/util/AccountUtil'; -import type { ClientCredentialsIdRoute } from './ClientCredentialsIdRoute'; +import { createErrorMessage } from '../../../../util/errors/ErrorUtil'; +import { InternalServerError } from '../../../../util/errors/InternalServerError'; +import { ACCOUNT_TYPE } from '../../account/util/LoginStorage'; +import type { AccountLoginStorage } from '../../account/util/LoginStorage'; import type { ClientCredentials, ClientCredentialsStore } from './ClientCredentialsStore'; +const STORAGE_TYPE = 'clientCredentials'; +const STORAGE_DESCRIPTION = { + label: 'string', + accountId: `id:${ACCOUNT_TYPE}`, + secret: 'string', + webId: 'string', +} as const; + /** - * A {@link ClientCredentialsStore} that uses a {@link KeyValueStorage} for storing the tokens. + * A {@link ClientCredentialsStore} that uses a {@link AccountLoginStorage} for storing the tokens. + * Needs to be initialized before it can be used. */ -export class BaseClientCredentialsStore implements ClientCredentialsStore { +export class BaseClientCredentialsStore extends Initializer implements ClientCredentialsStore { private readonly logger = getLoggerFor(this); - private readonly clientCredentialsRoute: ClientCredentialsIdRoute; - private readonly accountStore: AccountStore; - private readonly storage: KeyValueStorage; + private readonly storage: AccountLoginStorage<{ [STORAGE_TYPE]: typeof STORAGE_DESCRIPTION }>; + private initialized = false; - public constructor(clientCredentialsRoute: ClientCredentialsIdRoute, accountStore: AccountStore, - storage: KeyValueStorage) { - this.clientCredentialsRoute = clientCredentialsRoute; - this.accountStore = accountStore; + public constructor(storage: AccountLoginStorage) { + super(); this.storage = storage; } + // Initialize the type definitions + public async handle(): Promise { + if (this.initialized) { + return; + } + try { + await this.storage.defineType(STORAGE_TYPE, STORAGE_DESCRIPTION, false); + await this.storage.createIndex(STORAGE_TYPE, 'accountId'); + await this.storage.createIndex(STORAGE_TYPE, 'label'); + this.initialized = true; + } catch (cause: unknown) { + throw new InternalServerError(`Error defining client credentials in storage: ${createErrorMessage(cause)}`, + { cause }); + } + } + public async get(id: string): Promise { - return this.storage.get(id); + return this.storage.get(STORAGE_TYPE, id); } - public async add(id: string, webId: string, account: Account): Promise<{ secret: string; resource: string }> { - if (typeof account.webIds[webId] !== 'string') { - this.logger.warn(`Trying to create token for ${webId} which does not belong to account ${account.id}`); - throw new BadRequestHttpError('WebID does not belong to this account.'); + public async findByLabel(label: string): Promise { + const result = await this.storage.find(STORAGE_TYPE, { label }); + if (result.length === 0) { + return; } + return result[0]; + } + public async findByAccount(accountId: string): Promise { + return this.storage.find(STORAGE_TYPE, { accountId }); + } + + public async create(label: string, webId: string, accountId: string): Promise { const secret = randomBytes(64).toString('hex'); - const resource = this.clientCredentialsRoute.getPath({ accountId: account.id, clientCredentialsId: id }); - account.clientCredentials[id] = resource; - await safeUpdate(account, - this.accountStore, - (): Promise => this.storage.set(id, { accountId: account.id, secret, webId })); + this.logger.debug( + `Creating client credentials token with label ${label} for WebID ${webId} and account ${accountId}`, + ); - this.logger.debug(`Created client credentials token ${id} for WebID ${webId} and account ${account.id}`); - - return { secret, resource }; + return this.storage.create(STORAGE_TYPE, { accountId, label, webId, secret }); } - public async delete(id: string, account: Account): Promise { - const link = account.clientCredentials[id]; - - if (link) { - delete account.clientCredentials[id]; - await safeUpdate(account, - this.accountStore, - (): Promise => this.storage.delete(id)); - - this.logger.debug(`Deleted client credentials token ${id} for account ${account.id}`); - } + public async delete(id: string): Promise { + this.logger.debug(`Deleting client credentials token with ID ${id}`); + return this.storage.delete(STORAGE_TYPE, id); } } diff --git a/src/identity/interaction/client-credentials/util/ClientCredentialsStore.ts b/src/identity/interaction/client-credentials/util/ClientCredentialsStore.ts index 2088d6b97..b035f2c2c 100644 --- a/src/identity/interaction/client-credentials/util/ClientCredentialsStore.ts +++ b/src/identity/interaction/client-credentials/util/ClientCredentialsStore.ts @@ -1,23 +1,9 @@ -import type { Account } from '../../account/util/Account'; - -/** - * A client credentials token. - * If at some point the WebID is no longer registered to the account stored in this token, - * the token should be invalidated. - */ export interface ClientCredentials { - /** - * The identifier of the account that created the token. - */ - accountId: string; - /** - * The secret of the token. - */ - secret: string; - /** - * The WebID users will be identified as after using the token. - */ + id: string; + label: string; webId: string; + accountId: string; + secret: string; } /** @@ -25,23 +11,39 @@ export interface ClientCredentials { */ export interface ClientCredentialsStore { /** - * Find the {@link ClientCredentials} with the given label. Undefined if there is no match. - * @param label - Label of the credentials. - */ - get: (label: string) => Promise; - /** - * Creates new {@link ClientCredentials} and adds a reference to the account. - * Will error if the WebID is not registered to the account. + * Find the {@link ClientCredentials} with the given ID. * - * @param label - Identifier to use for the new credentials. + * @param id - ID of the token. + */ + get: (id: string) => Promise; + + /** + * Find the {@link ClientCredentials} with the given label. + * + * @param label - Label of the token. + */ + findByLabel: (label: string) => Promise; + + /** + * Find all tokens created by the given account. + * + * @param accountId - ID of the account. + */ + findByAccount: (accountId: string) => Promise; + + /** + * Creates new token. + * + * @param label - Identifier to use for the new token. * @param webId - WebID to identify as when using this token. * @param account - Account that is associated with this token. */ - add: (label: string, webId: string, account: Account) => Promise<{ secret: string; resource: string }>; + create: (label: string, webId: string, accountId: string) => Promise; + /** - * Deletes the token with the given identifier and removes the reference from the account. - * @param label - Identifier of the token. - * @param account - Account this token belongs to. + * Deletes the token with the given ID. + * + * @param id - ID of the token. */ - delete: (label: string, account: Account) => Promise; + delete: (id: string) => Promise; } diff --git a/src/identity/interaction/login/ResolveLoginHandler.ts b/src/identity/interaction/login/ResolveLoginHandler.ts index 8826cef41..c3c511bad 100644 --- a/src/identity/interaction/login/ResolveLoginHandler.ts +++ b/src/identity/interaction/login/ResolveLoginHandler.ts @@ -1,9 +1,7 @@ import { RepresentationMetadata } from '../../../http/representation/RepresentationMetadata'; import { getLoggerFor } from '../../../logging/LogUtil'; -import { InternalServerError } from '../../../util/errors/InternalServerError'; import { SOLID_HTTP } from '../../../util/Vocabularies'; -import type { AccountIdRoute } from '../account/AccountIdRoute'; -import { ACCOUNT_SETTINGS_REMEMBER_LOGIN } from '../account/util/Account'; +import { ACCOUNT_SETTINGS_REMEMBER_LOGIN } from '../account/util/AccountStore'; import type { AccountStore } from '../account/util/AccountStore'; import type { CookieStore } from '../account/util/CookieStore'; import type { Json, JsonRepresentation } from '../InteractionUtil'; @@ -29,7 +27,6 @@ export type LoginOutputType = { /** * A handler that takes care of all the necessary steps when logging a user in, * such as generating a cookie and setting the necessary OIDC information. - * It also sets the `resource` field of the response to the account URL. * Classes that resolve login methods should extend this class and implement the `login` method. */ export abstract class ResolveLoginHandler extends JsonInteractionHandler { @@ -37,23 +34,18 @@ export abstract class ResolveLoginHandler extends JsonInteractionHandler { protected readonly accountStore: AccountStore; protected readonly cookieStore: CookieStore; - protected readonly accountRoute: AccountIdRoute; - protected constructor(accountStore: AccountStore, cookieStore: CookieStore, accountRoute: AccountIdRoute) { + protected constructor(accountStore: AccountStore, cookieStore: CookieStore) { super(); this.accountStore = accountStore; this.cookieStore = cookieStore; - this.accountRoute = accountRoute; } public async handle(input: JsonInteractionHandlerInput): Promise { const result = await this.login(input); const { accountId, remember } = result.json; - const json: Json = { - ...result.json, - resource: this.accountRoute.getPath({ accountId }), - }; + const json: Json = { ...result.json }; // There is no need to output these fields in the response JSON delete json.accountId; @@ -95,13 +87,7 @@ export abstract class ResolveLoginHandler extends JsonInteractionHandler { protected async updateRememberSetting(accountId: string, remember?: boolean): Promise { if (typeof remember === 'boolean') { // Store the setting indicating if the user wants the cookie to persist - const account = await this.accountStore.get(accountId); - if (!account) { - this.logger.error(`Unable to find account ${accountId} that just logged in.`); - throw new InternalServerError('Unable to find account'); - } - account.settings[ACCOUNT_SETTINGS_REMEMBER_LOGIN] = remember; - await this.accountStore.update(account); + await this.accountStore.updateSetting(accountId, ACCOUNT_SETTINGS_REMEMBER_LOGIN, remember); this.logger.debug(`Updating account remember setting to ${remember}`); } } diff --git a/src/identity/interaction/oidc/PickWebIdHandler.ts b/src/identity/interaction/oidc/PickWebIdHandler.ts index 981c3668c..e44009d7b 100644 --- a/src/identity/interaction/oidc/PickWebIdHandler.ts +++ b/src/identity/interaction/oidc/PickWebIdHandler.ts @@ -4,13 +4,13 @@ import { getLoggerFor } from '../../../logging/LogUtil'; import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; import { FoundHttpError } from '../../../util/errors/FoundHttpError'; import type { ProviderFactory } from '../../configuration/ProviderFactory'; -import type { AccountStore } from '../account/util/AccountStore'; -import { getRequiredAccount } from '../account/util/AccountUtil'; +import { assertAccountId } from '../account/util/AccountUtil'; import type { JsonRepresentation } from '../InteractionUtil'; import { assertOidcInteraction, finishInteraction, forgetWebId } from '../InteractionUtil'; import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; import { JsonInteractionHandler } from '../JsonInteractionHandler'; import type { JsonView } from '../JsonView'; +import type { WebIdStore } from '../webid/util/WebIdStore'; import { parseSchema, validateWithError } from '../YupUtil'; const inSchema = object({ @@ -31,27 +31,28 @@ const inSchema = object({ export class PickWebIdHandler extends JsonInteractionHandler implements JsonView { private readonly logger = getLoggerFor(this); - private readonly accountStore: AccountStore; + private readonly webIdStore: WebIdStore; private readonly providerFactory: ProviderFactory; - public constructor(accountStore: AccountStore, providerFactory: ProviderFactory) { + public constructor(webIdStore: WebIdStore, providerFactory: ProviderFactory) { super(); - this.accountStore = accountStore; + this.webIdStore = webIdStore; this.providerFactory = providerFactory; } public async getView({ accountId }: JsonInteractionHandlerInput): Promise { - const account = await getRequiredAccount(this.accountStore, accountId); + assertAccountId(accountId); const description = parseSchema(inSchema); - return { json: { ...description, webIds: Object.keys(account.webIds) }}; + const webIds = (await this.webIdStore.findLinks(accountId)).map((link): string => link.webId); + return { json: { ...description, webIds }}; } public async handle({ oidcInteraction, accountId, json }: JsonInteractionHandlerInput): Promise { assertOidcInteraction(oidcInteraction); - const account = await getRequiredAccount(this.accountStore, accountId); + assertAccountId(accountId); const { webId, remember } = await validateWithError(inSchema, json); - if (!account.webIds[webId]) { + if (!await this.webIdStore.isLinked(webId, accountId)) { this.logger.warn(`Trying to pick WebID ${webId} which does not belong to account ${accountId}`); throw new BadRequestHttpError('WebID does not belong to this account.'); } diff --git a/src/identity/interaction/password/CreatePasswordHandler.ts b/src/identity/interaction/password/CreatePasswordHandler.ts index b8c3f19bb..cceb02993 100644 --- a/src/identity/interaction/password/CreatePasswordHandler.ts +++ b/src/identity/interaction/password/CreatePasswordHandler.ts @@ -1,23 +1,18 @@ import { object, string } from 'yup'; import { getLoggerFor } from '../../../logging/LogUtil'; -import { ConflictHttpError } from '../../../util/errors/ConflictHttpError'; -import type { AccountStore } from '../account/util/AccountStore'; -import { addLoginEntry, getRequiredAccount } from '../account/util/AccountUtil'; +import { assertAccountId } from '../account/util/AccountUtil'; import type { JsonRepresentation } from '../InteractionUtil'; import { JsonInteractionHandler } from '../JsonInteractionHandler'; import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; import type { JsonView } from '../JsonView'; import { parseSchema, validateWithError } from '../YupUtil'; import type { PasswordIdRoute } from './util/PasswordIdRoute'; -import { PASSWORD_METHOD } from './util/PasswordStore'; import type { PasswordStore } from './util/PasswordStore'; type OutType = { resource: string }; const inSchema = object({ - // Store e-mail addresses in lower case - email: string().trim().email().lowercase() - .required(), + email: string().trim().email().required(), password: string().trim().min(1).required(), }); @@ -28,48 +23,33 @@ export class CreatePasswordHandler extends JsonInteractionHandler imple protected readonly logger = getLoggerFor(this); private readonly passwordStore: PasswordStore; - private readonly accountStore: AccountStore; private readonly passwordRoute: PasswordIdRoute; - public constructor(passwordStore: PasswordStore, accountStore: AccountStore, passwordRoute: PasswordIdRoute) { + public constructor(passwordStore: PasswordStore, passwordRoute: PasswordIdRoute) { super(); this.passwordStore = passwordStore; - this.accountStore = accountStore; this.passwordRoute = passwordRoute; } - public async getView(): Promise { - return { json: parseSchema(inSchema) }; + public async getView({ accountId }: JsonInteractionHandlerInput): Promise { + assertAccountId(accountId); + const passwordLogins: Record = {}; + for (const { id, email } of await this.passwordStore.findByAccount(accountId)) { + passwordLogins[email] = this.passwordRoute.getPath({ accountId, passwordId: id }); + } + return { json: { ...parseSchema(inSchema), passwordLogins }}; } public async handle({ accountId, json }: JsonInteractionHandlerInput): Promise> { - const account = await getRequiredAccount(this.accountStore, accountId); - // Email will be in lowercase const { email, password } = await validateWithError(inSchema, json); + assertAccountId(accountId); - if (account.logins[PASSWORD_METHOD]?.[email]) { - throw new ConflictHttpError('This account already has a login method for this e-mail address.'); - } - - const resource = this.passwordRoute.getPath({ accountId: account.id, passwordId: encodeURIComponent(email) }); - - // We need to create the password entry first before trying to add it to the account, - // otherwise it might be impossible to remove it from the account again since - // you can't remove a login method from an account if it is the last one. - await this.passwordStore.create(email, account.id, password); + const passwordId = await this.passwordStore.create(email, accountId, password); + const resource = this.passwordRoute.getPath({ accountId, passwordId }); // If we ever want to add email verification this would have to be checked separately - await this.passwordStore.confirmVerification(email); - - try { - addLoginEntry(account, PASSWORD_METHOD, email, resource); - await this.accountStore.update(account); - } catch (error: unknown) { - this.logger.warn(`Error while updating account ${account.id}, reverting operation.`); - await this.passwordStore.delete(email); - throw error; - } + await this.passwordStore.confirmVerification(passwordId); return { json: { resource }}; } diff --git a/src/identity/interaction/password/DeletePasswordHandler.ts b/src/identity/interaction/password/DeletePasswordHandler.ts index 955f690d5..3b2056149 100644 --- a/src/identity/interaction/password/DeletePasswordHandler.ts +++ b/src/identity/interaction/password/DeletePasswordHandler.ts @@ -1,43 +1,31 @@ -import { NotFoundHttpError } from '../../../util/errors/NotFoundHttpError'; import type { EmptyObject } from '../../../util/map/MapUtil'; -import type { AccountStore } from '../account/util/AccountStore'; -import { ensureResource, getRequiredAccount, safeUpdate } from '../account/util/AccountUtil'; +import { parsePath, verifyAccountId } from '../account/util/AccountUtil'; import type { JsonRepresentation } from '../InteractionUtil'; import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; import { JsonInteractionHandler } from '../JsonInteractionHandler'; -import { PASSWORD_METHOD } from './util/PasswordStore'; +import type { PasswordIdRoute } from './util/PasswordIdRoute'; import type { PasswordStore } from './util/PasswordStore'; /** * Handles the deletion of a password login method. */ export class DeletePasswordHandler extends JsonInteractionHandler { - private readonly accountStore: AccountStore; private readonly passwordStore: PasswordStore; + private readonly passwordRoute: PasswordIdRoute; - public constructor(accountStore: AccountStore, passwordStore: PasswordStore) { + public constructor(passwordStore: PasswordStore, passwordRoute: PasswordIdRoute) { super(); - this.accountStore = accountStore; this.passwordStore = passwordStore; + this.passwordRoute = passwordRoute; } public async handle({ target, accountId }: JsonInteractionHandlerInput): Promise> { - const account = await getRequiredAccount(this.accountStore, accountId); + const match = parsePath(this.passwordRoute, target.path); - const passwordLogins = account.logins[PASSWORD_METHOD]; - if (!passwordLogins) { - throw new NotFoundHttpError(); - } + const login = await this.passwordStore.get(match.passwordId); + verifyAccountId(accountId, login?.accountId); - const email = ensureResource(passwordLogins, target.path); - - // This needs to happen first since this checks that there is at least 1 login method - delete passwordLogins[email]; - - // Delete the password data and revert if something goes wrong - await safeUpdate(account, - this.accountStore, - (): Promise => this.passwordStore.delete(email)); + await this.passwordStore.delete(match.passwordId); return { json: {}}; } diff --git a/src/identity/interaction/password/ForgotPasswordHandler.ts b/src/identity/interaction/password/ForgotPasswordHandler.ts index 237278057..631dbce32 100644 --- a/src/identity/interaction/password/ForgotPasswordHandler.ts +++ b/src/identity/interaction/password/ForgotPasswordHandler.ts @@ -76,11 +76,11 @@ export class ForgotPasswordHandler extends JsonInteractionHandler imple public async handle({ json }: JsonInteractionHandlerInput): Promise> { const { email } = await validateWithError(inSchema, json); - const accountId = await this.passwordStore.get(email); + const payload = await this.passwordStore.findByEmail(email); - if (accountId) { + if (payload?.id) { try { - const recordId = await this.forgotPasswordStore.generate(email); + const recordId = await this.forgotPasswordStore.generate(payload.id); await this.sendResetMail(recordId, email); } catch (error: unknown) { // This error can not be thrown for privacy reasons. diff --git a/src/identity/interaction/password/PasswordLoginHandler.ts b/src/identity/interaction/password/PasswordLoginHandler.ts index ec497b319..94ea8d8fb 100644 --- a/src/identity/interaction/password/PasswordLoginHandler.ts +++ b/src/identity/interaction/password/PasswordLoginHandler.ts @@ -1,6 +1,5 @@ import { boolean, object, string } from 'yup'; import { getLoggerFor } from '../../../logging/LogUtil'; -import type { AccountIdRoute } from '../account/AccountIdRoute'; import type { AccountStore } from '../account/util/AccountStore'; import type { CookieStore } from '../account/util/CookieStore'; import type { JsonRepresentation } from '../InteractionUtil'; @@ -21,7 +20,6 @@ export interface PasswordLoginHandlerArgs { accountStore: AccountStore; passwordStore: PasswordStore; cookieStore: CookieStore; - accountRoute: AccountIdRoute; } /** @@ -33,7 +31,7 @@ export class PasswordLoginHandler extends ResolveLoginHandler implements JsonVie private readonly passwordStore: PasswordStore; public constructor(args: PasswordLoginHandlerArgs) { - super(args.accountStore, args.cookieStore, args.accountRoute); + super(args.accountStore, args.cookieStore); this.passwordStore = args.passwordStore; } @@ -44,7 +42,7 @@ export class PasswordLoginHandler extends ResolveLoginHandler implements JsonVie public async login({ json }: JsonInteractionHandlerInput): Promise> { const { email, password, remember } = await validateWithError(inSchema, json); // Try to log in, will error if email/password combination is invalid - const accountId = await this.passwordStore.authenticate(email, password); + const { accountId } = await this.passwordStore.authenticate(email, password); this.logger.debug(`Logging in user ${email}`); return { json: { accountId, remember }}; diff --git a/src/identity/interaction/password/ResetPasswordHandler.ts b/src/identity/interaction/password/ResetPasswordHandler.ts index 73428333e..6f87abebc 100644 --- a/src/identity/interaction/password/ResetPasswordHandler.ts +++ b/src/identity/interaction/password/ResetPasswordHandler.ts @@ -47,16 +47,16 @@ export class ResetPasswordHandler extends JsonInteractionHandler im * Resets the password for the account associated with the given recordId. */ private async resetPassword(recordId: string, newPassword: string): Promise { - const email = await this.forgotPasswordStore.get(recordId); + const id = await this.forgotPasswordStore.get(recordId); - if (!email) { + if (!id) { this.logger.warn(`Trying to use invalid reset URL with record ID ${recordId}`); throw new BadRequestHttpError('This reset password link is no longer valid.'); } - await this.passwordStore.update(email, newPassword); + await this.passwordStore.update(id, newPassword); await this.forgotPasswordStore.delete(recordId); - this.logger.debug(`Resetting password for user ${email}`); + this.logger.debug(`Resetting password for login ${id}`); } } diff --git a/src/identity/interaction/password/UpdatePasswordHandler.ts b/src/identity/interaction/password/UpdatePasswordHandler.ts index 5227371f1..ce26bb59a 100644 --- a/src/identity/interaction/password/UpdatePasswordHandler.ts +++ b/src/identity/interaction/password/UpdatePasswordHandler.ts @@ -2,14 +2,13 @@ import { object, string } from 'yup'; import { getLoggerFor } from '../../../logging/LogUtil'; import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; import type { EmptyObject } from '../../../util/map/MapUtil'; -import type { AccountStore } from '../account/util/AccountStore'; -import { ensureResource, getRequiredAccount } from '../account/util/AccountUtil'; +import { parsePath, verifyAccountId } from '../account/util/AccountUtil'; import type { JsonRepresentation } from '../InteractionUtil'; import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; import { JsonInteractionHandler } from '../JsonInteractionHandler'; import type { JsonView } from '../JsonView'; import { parseSchema, validateWithError } from '../YupUtil'; -import { PASSWORD_METHOD } from './util/PasswordStore'; +import type { PasswordIdRoute } from './util/PasswordIdRoute'; import type { PasswordStore } from './util/PasswordStore'; const inSchema = object({ @@ -23,13 +22,13 @@ const inSchema = object({ export class UpdatePasswordHandler extends JsonInteractionHandler implements JsonView { private readonly logger = getLoggerFor(this); - private readonly accountStore: AccountStore; private readonly passwordStore: PasswordStore; + private readonly passwordRoute: PasswordIdRoute; - public constructor(accountStore: AccountStore, passwordStore: PasswordStore) { + public constructor(passwordStore: PasswordStore, passwordRoute: PasswordIdRoute) { super(); - this.accountStore = accountStore; this.passwordStore = passwordStore; + this.passwordRoute = passwordRoute; } public async getView(): Promise { @@ -38,21 +37,22 @@ export class UpdatePasswordHandler extends JsonInteractionHandler i public async handle(input: JsonInteractionHandlerInput): Promise> { const { target, accountId, json } = input; - const account = await getRequiredAccount(this.accountStore, accountId); - - const email = ensureResource(account.logins[PASSWORD_METHOD], target.path); const { oldPassword, newPassword } = await validateWithError(inSchema, json); + const match = parsePath(this.passwordRoute, target.path); + + const login = await this.passwordStore.get(match.passwordId); + verifyAccountId(accountId, login?.accountId); // Make sure the old password is correct try { - await this.passwordStore.authenticate(email, oldPassword); + await this.passwordStore.authenticate(login.email, oldPassword); } catch { - this.logger.warn(`Invalid password when trying to reset for email ${email}`); + this.logger.warn(`Invalid password when trying to reset for email ${login.email}`); throw new BadRequestHttpError('Old password is invalid.'); } - await this.passwordStore.update(email, newPassword); + await this.passwordStore.update(match.passwordId, newPassword); return { json: {}}; } diff --git a/src/identity/interaction/password/util/BasePasswordStore.ts b/src/identity/interaction/password/util/BasePasswordStore.ts index f5d0579c1..01471c0e0 100644 --- a/src/identity/interaction/password/util/BasePasswordStore.ts +++ b/src/identity/interaction/password/util/BasePasswordStore.ts @@ -1,103 +1,127 @@ import { hash, compare } from 'bcryptjs'; +import { Initializer } from '../../../../init/Initializer'; import { getLoggerFor } from '../../../../logging/LogUtil'; -import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage'; import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; +import { createErrorMessage } from '../../../../util/errors/ErrorUtil'; import { ForbiddenHttpError } from '../../../../util/errors/ForbiddenHttpError'; +import { InternalServerError } from '../../../../util/errors/InternalServerError'; +import { ACCOUNT_TYPE } from '../../account/util/LoginStorage'; +import type { AccountLoginStorage } from '../../account/util/LoginStorage'; import type { PasswordStore } from './PasswordStore'; -/** - * A payload to persist a user account - */ -export interface LoginPayload { - accountId: string; - password: string; - verified: boolean; -} +const STORAGE_TYPE = 'password'; +const STORAGE_DESCRIPTION = { + email: 'string', + password: 'string', + verified: 'boolean', + accountId: `id:${ACCOUNT_TYPE}`, +} as const; /** * A {@link PasswordStore} that uses a {@link KeyValueStorage} to store the entries. * Passwords are hashed and salted. * Default `saltRounds` is 10. */ -export class BasePasswordStore implements PasswordStore { +export class BasePasswordStore extends Initializer implements PasswordStore { private readonly logger = getLoggerFor(this); - private readonly storage: KeyValueStorage; + private readonly storage: AccountLoginStorage<{ [STORAGE_TYPE]: typeof STORAGE_DESCRIPTION }>; private readonly saltRounds: number; + private initialized = false; - public constructor(storage: KeyValueStorage, saltRounds = 10) { + public constructor(storage: AccountLoginStorage, saltRounds = 10) { + super(); this.storage = storage; this.saltRounds = saltRounds; } - /** - * Helper function that converts the given e-mail to a resource identifier - * and retrieves the login data from the internal storage. - * - * Will error if `checkExistence` is true and there is no login data for that email. - */ - private async getLoginPayload(email: string, checkExistence: true): Promise<{ key: string; payload: LoginPayload }>; - private async getLoginPayload(email: string, checkExistence: false): Promise<{ key: string; payload?: LoginPayload }>; - private async getLoginPayload(email: string, checkExistence: boolean): - Promise<{ key: string; payload?: LoginPayload }> { - const key = encodeURIComponent(email.toLowerCase()); - const payload = await this.storage.get(key); - if (checkExistence && !payload) { - this.logger.warn(`Trying to get account info for unknown email ${email}`); - throw new ForbiddenHttpError('Login does not exist.'); + // Initialize the type definitions + public async handle(): Promise { + if (this.initialized) { + return; + } + try { + await this.storage.defineType(STORAGE_TYPE, STORAGE_DESCRIPTION, true); + await this.storage.createIndex(STORAGE_TYPE, 'accountId'); + await this.storage.createIndex(STORAGE_TYPE, 'email'); + this.initialized = true; + } catch (cause: unknown) { + throw new InternalServerError(`Error defining email/password in storage: ${createErrorMessage(cause)}`, + { cause }); } - return { key, payload }; } - public async get(email: string): Promise { - const { payload } = await this.getLoginPayload(email, false); - return payload?.accountId; - } - - public async authenticate(email: string, password: string): Promise { - const { payload } = await this.getLoginPayload(email, true); - if (!payload.verified) { - this.logger.warn(`Trying to get account info for unverified email ${email}`); - throw new ForbiddenHttpError('Login still needs to be verified.'); - } - if (!await compare(password, payload.password)) { - this.logger.warn(`Incorrect password for email ${email}`); - throw new ForbiddenHttpError('Incorrect password.'); - } - return payload.accountId; - } - - public async create(email: string, accountId: string, password: string): Promise { - const { key, payload } = await this.getLoginPayload(email, false); - if (payload) { + public async create(email: string, accountId: string, password: string): Promise { + if (await this.findByEmail(email)) { this.logger.warn(`Trying to create duplicate login for email ${email}`); throw new BadRequestHttpError('There already is a login for this e-mail address.'); } - await this.storage.set(key, { + const payload = await this.storage.create(STORAGE_TYPE, { accountId, + email: email.toLowerCase(), password: await hash(password, this.saltRounds), verified: false, }); + return payload.id; } - public async confirmVerification(email: string): Promise { - const { key, payload } = await this.getLoginPayload(email, true); - payload.verified = true; - await this.storage.set(key, payload); - } - - public async update(email: string, password: string): Promise { - const { key, payload } = await this.getLoginPayload(email, true); - payload.password = await hash(password, this.saltRounds); - await this.storage.set(key, payload); - } - - public async delete(email: string): Promise { - const { key, payload } = await this.getLoginPayload(email, false); - const exists = Boolean(payload); - if (exists) { - await this.storage.delete(key); + public async get(id: string): Promise<{ email: string; accountId: string } | undefined> { + const result = await this.storage.get(STORAGE_TYPE, id); + if (!result) { + return; } - return exists; + return { email: result.email, accountId: result.accountId }; + } + + public async findByEmail(email: string): Promise<{ accountId: string; id: string } | undefined> { + const payload = await this.storage.find(STORAGE_TYPE, { email: email.toLowerCase() }); + if (payload.length === 0) { + return; + } + return { accountId: payload[0].accountId, id: payload[0].id }; + } + + public async findByAccount(accountId: string): Promise<{ id: string; email: string }[]> { + return (await this.storage.find(STORAGE_TYPE, { accountId })) + .map(({ id, email }): { id: string; email: string } => ({ id, email })); + } + + public async confirmVerification(id: string): Promise { + if (!await this.storage.has(STORAGE_TYPE, id)) { + this.logger.warn(`Trying to verify unknown password login ${id}`); + throw new ForbiddenHttpError('Login does not exist.'); + } + + await this.storage.setField(STORAGE_TYPE, id, 'verified', true); + } + + public async authenticate(email: string, password: string): Promise<{ accountId: string; id: string }> { + const payload = await this.storage.find(STORAGE_TYPE, { email: email.toLowerCase() }); + if (payload.length === 0) { + this.logger.warn(`Trying to get account info for unknown email ${email}`); + throw new ForbiddenHttpError('Invalid email/password combination.'); + } + if (!await compare(password, payload[0].password)) { + this.logger.warn(`Incorrect password for email ${email}`); + throw new ForbiddenHttpError('Invalid email/password combination.'); + } + const { verified, accountId, id } = payload[0]; + if (!verified) { + this.logger.warn(`Trying to get account info for unverified email ${email}`); + throw new ForbiddenHttpError('Login still needs to be verified.'); + } + return { accountId, id }; + } + + public async update(id: string, password: string): Promise { + if (!await this.storage.has(STORAGE_TYPE, id)) { + this.logger.warn(`Trying to update unknown password login ${id}`); + throw new ForbiddenHttpError('Login does not exist.'); + } + await this.storage.setField(STORAGE_TYPE, id, 'password', await hash(password, this.saltRounds)); + } + + public async delete(id: string): Promise { + return this.storage.delete(STORAGE_TYPE, id); } } diff --git a/src/identity/interaction/password/util/ForgotPasswordStore.ts b/src/identity/interaction/password/util/ForgotPasswordStore.ts index 6bbdde0a0..2fbd407c1 100644 --- a/src/identity/interaction/password/util/ForgotPasswordStore.ts +++ b/src/identity/interaction/password/util/ForgotPasswordStore.ts @@ -6,10 +6,10 @@ export interface ForgotPasswordStore { * Creates a Forgot Password Confirmation Record. This will be to remember that * a user has made a request to reset a password. Throws an error if the email doesn't * exist. - * @param email - The user's email. + * @param id - ID of the email/password login object. * @returns The record id. This should be included in the reset password link. */ - generate: (email: string) => Promise; + generate: (id: string) => Promise; /** * Gets the email associated with the forgot password confirmation record diff --git a/src/identity/interaction/password/util/PasswordStore.ts b/src/identity/interaction/password/util/PasswordStore.ts index 426057020..fbe0ed4e6 100644 --- a/src/identity/interaction/password/util/PasswordStore.ts +++ b/src/identity/interaction/password/util/PasswordStore.ts @@ -8,47 +8,66 @@ export const PASSWORD_METHOD = 'password'; */ export interface PasswordStore { /** - * Finds the Account ID linked to this email address. - * @param email - The email address of which to find the account. - * @returns The relevant Account ID or `undefined` if there is no match. - */ - get: (email: string) => Promise; - - /** - * Authenticate if the email and password are correct and return the Account ID if it is. - * Throw an error if it is not. - * @param email - The user's email. - * @param password - This user's password. - * @returns The user's Account ID. - */ - authenticate: (email: string, password: string) => Promise; - - /** - * Stores a new login entry for this account. - * @param email - Account email. + * Creates a new login entry for this account. + * + * @param email - Email to log in with. * @param accountId - Account ID. - * @param password - Account password. + * @param password - Password to authenticate with. */ - create: (email: string, accountId: string, password: string) => Promise; + create: (email: string, accountId: string, password: string) => Promise; /** - * Confirms that the e-mail address has been verified. This can be used with, for example, email verification. + * Finds the account and email associated with this login ID. + * + * @param id - The ID of the login object. + */ + get: (id: string) => Promise<{ email: string; accountId: string } | undefined>; + + /** + * Finds the account and login ID associated with this email. + * + * @param email - Email to find the information for. + */ + findByEmail: (email: string) => Promise<{ accountId: string; id: string } | undefined>; + + /** + * Find all login objects created by this account. + * + * @param accountId - ID of the account to find the logins for. + */ + findByAccount: (accountId: string) => Promise<{ id: string; email: string }[]>; + + /** + * Confirms that the login has been verified. + * This can be used with, for example, email verification. * The login can only be used after it is verified. * In case verification is not required, this should be called immediately after the `create` call. - * @param email - The account email. + * + * @param id - ID of the login. */ - confirmVerification: (email: string) => Promise; + confirmVerification: (id: string) => Promise; + + /** + * Authenticate if the email and password are correct and return the account and login ID if they are. + * Throw an error if they are not. + * + * @param email - The user's email. + * @param password - This user's password. + */ + authenticate: (email: string, password: string) => Promise<{ accountId: string; id: string }>; /** * Changes the password. - * @param email - The user's email. - * @param password - The user's password. + * + * @param id - ID of the login object. + * @param password - The new password. */ - update: (email: string, password: string) => Promise; + update: (id: string, password: string) => Promise; /** - * Delete the login entry of this email address. - * @param email - The user's email. + * Delete the login entry. + * + * @param id - ID of the login object. */ - delete: (email: string) => Promise; + delete: (id: string) => Promise; } diff --git a/src/identity/interaction/pod/CreatePodHandler.ts b/src/identity/interaction/pod/CreatePodHandler.ts index 3f17c0efc..3641e4063 100644 --- a/src/identity/interaction/pod/CreatePodHandler.ts +++ b/src/identity/interaction/pod/CreatePodHandler.ts @@ -6,14 +6,15 @@ import type { IdentifierGenerator } from '../../../pods/generate/IdentifierGener import type { PodSettings } from '../../../pods/settings/PodSettings'; import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; import { joinUrl } from '../../../util/PathUtil'; -import type { AccountStore } from '../account/util/AccountStore'; -import { getRequiredAccount } from '../account/util/AccountUtil'; +import { assertAccountId } from '../account/util/AccountUtil'; import type { JsonRepresentation } from '../InteractionUtil'; import { JsonInteractionHandler } from '../JsonInteractionHandler'; import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; import type { JsonView } from '../JsonView'; import type { WebIdStore } from '../webid/util/WebIdStore'; +import type { WebIdLinkRoute } from '../webid/WebIdLinkRoute'; import { parseSchema, URL_SCHEMA, validateWithError } from '../YupUtil'; +import type { PodIdRoute } from './PodIdRoute'; import type { PodStore } from './util/PodStore'; const inSchema = object({ @@ -38,10 +39,6 @@ export interface CreatePodHandlerArgs { * The path of where the WebID will be generated by the template, relative to the pod URL. */ relativeWebIdPath: string; - /** - * Account data store. - */ - accountStore: AccountStore; /** * WebID data store. */ @@ -50,6 +47,14 @@ export interface CreatePodHandlerArgs { * Pod data store. */ podStore: PodStore; + /** + * Route to generate WebID link resource URLs. + */ + webIdLinkRoute: WebIdLinkRoute; + /** + * Route to generate Pod ID resource URLs + */ + podIdRoute: PodIdRoute; /** * Whether it is allowed to generate a pod in the root of the server. */ @@ -73,9 +78,10 @@ export class CreatePodHandler extends JsonInteractionHandler implements private readonly baseUrl: string; private readonly identifierGenerator: IdentifierGenerator; private readonly relativeWebIdPath: string; - private readonly accountStore: AccountStore; private readonly webIdStore: WebIdStore; private readonly podStore: PodStore; + private readonly webIdLinkRoute: WebIdLinkRoute; + private readonly podIdRoute: PodIdRoute; private readonly inSchema: typeof inSchema; @@ -84,9 +90,10 @@ export class CreatePodHandler extends JsonInteractionHandler implements this.baseUrl = args.baseUrl; this.identifierGenerator = args.identifierGenerator; this.relativeWebIdPath = args.relativeWebIdPath; - this.accountStore = args.accountStore; this.webIdStore = args.webIdStore; this.podStore = args.podStore; + this.webIdLinkRoute = args.webIdLinkRoute; + this.podIdRoute = args.podIdRoute; this.inSchema = inSchema.clone(); @@ -96,15 +103,19 @@ export class CreatePodHandler extends JsonInteractionHandler implements } } - public async getView(): Promise { - return { json: parseSchema(this.inSchema) }; + public async getView({ accountId }: JsonInteractionHandlerInput): Promise { + assertAccountId(accountId); + const pods: Record = {}; + for (const { id, baseUrl } of await this.podStore.findPods(accountId)) { + pods[baseUrl] = this.podIdRoute.getPath({ accountId, podId: id }); + } + return { json: { ...parseSchema(this.inSchema), pods }}; } public async handle({ json, accountId }: JsonInteractionHandlerInput): Promise> { - const account = await getRequiredAccount(this.accountStore, accountId); - // In case the class was not initialized with allowRoot: false, missing name values will result in an error const { name, settings } = await validateWithError(inSchema, json); + assertAccountId(accountId); const baseIdentifier = this.generateBaseIdentifier(name); // Either the input WebID or the one generated in the pod @@ -120,6 +131,7 @@ export class CreatePodHandler extends JsonInteractionHandler implements // Link the WebID to the account immediately if no WebID was provided. // This WebID will be necessary anyway to access the data in the pod, // so might as well link it to the account immediately. + let webIdLink: string | undefined; let webIdResource: string | undefined; if (linkWebId) { // It is important that this check happens here. @@ -127,32 +139,31 @@ export class CreatePodHandler extends JsonInteractionHandler implements // this link would be deleted if pod creation fails, // since we clean up the WebID link again afterwards. // Current implementation of the {@link WebIdStore} also has this check but better safe than sorry. - if (account.webIds[webId]) { - this.logger.warn('Trying to create pod which would generate a WebID that already is linked to this account'); + if (await this.webIdStore.isLinked(webId, accountId)) { + this.logger.warn('Trying to create pod which would generate a WebID that is already linked to this account'); throw new BadRequestHttpError(`${webId} is already registered to this account.`); } - webIdResource = await this.webIdStore.add(webId, account); + webIdLink = await this.webIdStore.create(webId, accountId); + webIdResource = this.webIdLinkRoute.getPath({ accountId, webIdLink }); // Need to have the necessary `solid:oidcIssuer` triple if the WebID is linked podSettings.oidcIssuer = this.baseUrl; } // Create the pod - let podResource: string; + let podId: string; try { - podResource = await this.podStore.create(account, podSettings, !name); + podId = await this.podStore.create(accountId, podSettings, !name); } catch (error: unknown) { // Undo the WebID linking if pod creation fails - if (linkWebId) { - // There was an error while trying to update the account above, - // so we shouldn't assume the account object we have is still valid. - const currentAccount = await getRequiredAccount(this.accountStore, accountId); - await this.webIdStore.delete(webId, currentAccount); + if (webIdLink) { + await this.webIdStore.delete(webIdLink); } throw error; } + const podResource = this.podIdRoute.getPath({ accountId, podId }); return { json: { pod: baseIdentifier.path, webId, podResource, webIdResource }}; } diff --git a/src/identity/interaction/pod/util/BasePodStore.ts b/src/identity/interaction/pod/util/BasePodStore.ts index f225983d6..ae31b7154 100644 --- a/src/identity/interaction/pod/util/BasePodStore.ts +++ b/src/identity/interaction/pod/util/BasePodStore.ts @@ -1,48 +1,81 @@ -import { createHash } from 'crypto'; +import { Initializer } from '../../../../init/Initializer'; import { getLoggerFor } from '../../../../logging/LogUtil'; import type { PodManager } from '../../../../pods/PodManager'; import type { PodSettings } from '../../../../pods/settings/PodSettings'; import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; import { createErrorMessage } from '../../../../util/errors/ErrorUtil'; -import type { Account } from '../../account/util/Account'; -import type { AccountStore } from '../../account/util/AccountStore'; -import { safeUpdate } from '../../account/util/AccountUtil'; -import type { PodIdRoute } from '../PodIdRoute'; +import { InternalServerError } from '../../../../util/errors/InternalServerError'; +import { ACCOUNT_TYPE } from '../../account/util/LoginStorage'; +import type { AccountLoginStorage } from '../../account/util/LoginStorage'; import type { PodStore } from './PodStore'; +const STORAGE_TYPE = 'pod'; +const STORAGE_DESCRIPTION = { + baseUrl: 'string', + accountId: `id:${ACCOUNT_TYPE}`, +} as const; + /** - * A {@link PodStore} implementation using a {@link PodManager} to create pods. + * A {@link PodStore} implementation using a {@link PodManager} to create pods + * and a {@link AccountLoginStorage} to store the data. + * Needs to be initialized before it can be used. */ -export class BasePodStore implements PodStore { +export class BasePodStore extends Initializer implements PodStore { private readonly logger = getLoggerFor(this); - private readonly accountStore: AccountStore; - private readonly podRoute: PodIdRoute; + private readonly storage: AccountLoginStorage<{ [STORAGE_TYPE]: typeof STORAGE_DESCRIPTION }>; private readonly manager: PodManager; + private initialized = false; - public constructor(accountStore: AccountStore, podRoute: PodIdRoute, manager: PodManager) { - this.accountStore = accountStore; - this.podRoute = podRoute; + public constructor(storage: AccountLoginStorage, manager: PodManager) { + super(); + this.storage = storage; this.manager = manager; } - public async create(account: Account, settings: PodSettings, overwrite: boolean): Promise { - const base = settings.base.path; - // The unique identifier of the pod-account link - const podId = createHash('sha256').update(base).digest('hex'); - const resource = this.podRoute.getPath({ accountId: account.id, podId }); - account.pods[base] = resource; + // Initialize the type definitions + public async handle(): Promise { + if (this.initialized) { + return; + } + try { + await this.storage.defineType(STORAGE_TYPE, STORAGE_DESCRIPTION, false); + await this.storage.createIndex(STORAGE_TYPE, 'accountId'); + await this.storage.createIndex(STORAGE_TYPE, 'baseUrl'); + this.initialized = true; + } catch (cause: unknown) { + throw new InternalServerError(`Error defining pods in storage: ${createErrorMessage(cause)}`, + { cause }); + } + } + + public async create(accountId: string, settings: PodSettings, overwrite: boolean): Promise { + // Adding pod to storage first as we cannot undo creating the pod below. + // This call might also fail because there is no login method yet on the account. + const pod = await this.storage.create(STORAGE_TYPE, { baseUrl: settings.base.path, accountId }); try { - await safeUpdate(account, - this.accountStore, - (): Promise => this.manager.createPod(settings, overwrite)); + await this.manager.createPod(settings, overwrite); } catch (error: unknown) { - this.logger.warn(`Pod creation failed for account ${account.id}: ${createErrorMessage(error)}`); - throw new BadRequestHttpError(`Pod creation failed: ${createErrorMessage(error)}`); + this.logger.warn(`Pod creation failed for account ${accountId}: ${createErrorMessage(error)}`); + await this.storage.delete(STORAGE_TYPE, pod.id); + throw new BadRequestHttpError(`Pod creation failed: ${createErrorMessage(error)}`, { cause: error }); } - this.logger.debug(`Created pod ${settings.name} for account ${account.id}`); + this.logger.debug(`Created pod ${settings.name} for account ${accountId}`); - return resource; + return pod.id; + } + + public async findAccount(baseUrl: string): Promise { + const result = await this.storage.find(STORAGE_TYPE, { baseUrl }); + if (result.length === 0) { + return; + } + return result[0].accountId; + } + + public async findPods(accountId: string): Promise<{ id: string; baseUrl: string }[]> { + return (await this.storage.find(STORAGE_TYPE, { accountId })) + .map(({ id, baseUrl }): { id: string; baseUrl: string } => ({ id, baseUrl })); } } diff --git a/src/identity/interaction/pod/util/PodStore.ts b/src/identity/interaction/pod/util/PodStore.ts index d516ffe3e..24826987d 100644 --- a/src/identity/interaction/pod/util/PodStore.ts +++ b/src/identity/interaction/pod/util/PodStore.ts @@ -1,18 +1,31 @@ import type { PodSettings } from '../../../../pods/settings/PodSettings'; -import type { Account } from '../../account/util/Account'; /** - * Can be used to create new pods. + * Can be used to create new pods and find relevant information. */ export interface PodStore { /** * Creates a new pod and updates the account accordingly. * - * @param account - Account to create a pod for. Object will be updated in place. + * @param accountId - Identifier of the account that is creating the account.. * @param settings - Settings to create a pod with. * @param overwrite - If the pod is allowed to overwrite existing data. * - * @returns The resource corresponding to the created pod for this account. + * @returns The ID of the new pod resource. */ - create: (account: Account, settings: PodSettings, overwrite: boolean) => Promise; + create: (accountId: string, settings: PodSettings, overwrite: boolean) => Promise; + + /** + * Find the ID of the account that created the given pod. + * + * @param baseUrl - The pod base URL. + */ + findAccount: (baseUrl: string) => Promise; + + /** + * Find all the pod resources created by the given account ID. + * + * @param accountId - Account ID to find pod resources for. + */ + findPods: (accountId: string) => Promise<{ id: string; baseUrl: string }[]>; } diff --git a/src/identity/interaction/webid/LinkWebIdHandler.ts b/src/identity/interaction/webid/LinkWebIdHandler.ts index 55d29a20b..07e3835ef 100644 --- a/src/identity/interaction/webid/LinkWebIdHandler.ts +++ b/src/identity/interaction/webid/LinkWebIdHandler.ts @@ -1,14 +1,14 @@ import { object } from 'yup'; import { getLoggerFor } from '../../../logging/LogUtil'; +import type { StorageLocationStrategy } from '../../../server/description/StorageLocationStrategy'; import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; -import type { IdentifierStrategy } from '../../../util/identifiers/IdentifierStrategy'; import type { OwnershipValidator } from '../../ownership/OwnershipValidator'; -import type { AccountStore } from '../account/util/AccountStore'; -import { getRequiredAccount } from '../account/util/AccountUtil'; +import { assertAccountId } from '../account/util/AccountUtil'; import type { JsonRepresentation } from '../InteractionUtil'; import { JsonInteractionHandler } from '../JsonInteractionHandler'; import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; import type { JsonView } from '../JsonView'; +import type { PodStore } from '../pod/util/PodStore'; import { parseSchema, URL_SCHEMA, validateWithError } from '../YupUtil'; import type { WebIdStore } from './util/WebIdStore'; import type { WebIdLinkRoute } from './WebIdLinkRoute'; @@ -34,9 +34,9 @@ export interface LinkWebIdHandlerArgs { */ ownershipValidator: OwnershipValidator; /** - * Account store to store updated data. + * Pod store to find out if the account created the pod containing the WebID. */ - accountStore: AccountStore; + podStore: PodStore; /** * WebID store to store WebID links. */ @@ -48,7 +48,7 @@ export interface LinkWebIdHandlerArgs { /** * Before calling the {@link OwnershipValidator}, we first check if the target WebID is in a pod owned by the user. */ - identifierStrategy: IdentifierStrategy; + storageStrategy: StorageLocationStrategy; } /** @@ -60,40 +60,56 @@ export class LinkWebIdHandler extends JsonInteractionHandler implements private readonly baseUrl: string; private readonly ownershipValidator: OwnershipValidator; - private readonly accountStore: AccountStore; + private readonly podStore: PodStore; private readonly webIdStore: WebIdStore; - private readonly identifierStrategy: IdentifierStrategy; + private readonly webIdRoute: WebIdLinkRoute; + private readonly storageStrategy: StorageLocationStrategy; public constructor(args: LinkWebIdHandlerArgs) { super(); this.baseUrl = args.baseUrl; this.ownershipValidator = args.ownershipValidator; - this.accountStore = args.accountStore; + this.podStore = args.podStore; this.webIdStore = args.webIdStore; - this.identifierStrategy = args.identifierStrategy; + this.webIdRoute = args.webIdRoute; + this.storageStrategy = args.storageStrategy; } - public async getView(): Promise { - return { json: parseSchema(inSchema) }; + public async getView({ accountId }: JsonInteractionHandlerInput): Promise { + assertAccountId(accountId); + const webIdLinks: Record = {}; + for (const { id, webId } of await this.webIdStore.findLinks(accountId)) { + webIdLinks[webId] = this.webIdRoute.getPath({ accountId, webIdLink: id }); + } + return { json: { ...parseSchema(inSchema), webIdLinks }}; } public async handle({ accountId, json }: JsonInteractionHandlerInput): Promise> { - const account = await getRequiredAccount(this.accountStore, accountId); + assertAccountId(accountId); const { webId } = await validateWithError(inSchema, json); - if (account.webIds[webId]) { + if (await this.webIdStore.isLinked(webId, accountId)) { this.logger.warn(`Trying to link WebID ${webId} to account ${accountId} which already has this link`); throw new BadRequestHttpError(`${webId} is already registered to this account.`); } - // Only need to check ownership if the account is not the owner - const isOwner = Object.keys(account.pods) - .some((pod): boolean => this.identifierStrategy.contains({ path: pod }, { path: webId }, true)); - if (!isOwner) { + // Only need to check ownership if the account did not create the pod + let isCreator = false; + try { + const pod = await this.storageStrategy.getStorageIdentifier({ path: webId }); + const creator = await this.podStore.findAccount(pod.path); + isCreator = accountId === creator; + } catch { + // Probably a WebID not hosted on the server + } + + if (!isCreator) { await this.ownershipValidator.handleSafe({ webId }); } - const resource = await this.webIdStore.add(webId, account); + + const webIdLink = await this.webIdStore.create(webId, accountId); + const resource = this.webIdRoute.getPath({ accountId, webIdLink }); return { json: { resource, webId, oidcIssuer: this.baseUrl }}; } diff --git a/src/identity/interaction/webid/UnlinkWebIdHandler.ts b/src/identity/interaction/webid/UnlinkWebIdHandler.ts index be39104c8..399d9de26 100644 --- a/src/identity/interaction/webid/UnlinkWebIdHandler.ts +++ b/src/identity/interaction/webid/UnlinkWebIdHandler.ts @@ -1,31 +1,31 @@ import type { EmptyObject } from '../../../util/map/MapUtil'; -import type { AccountStore } from '../account/util/AccountStore'; -import { ensureResource, getRequiredAccount } from '../account/util/AccountUtil'; +import { parsePath, verifyAccountId } from '../account/util/AccountUtil'; import type { JsonRepresentation } from '../InteractionUtil'; import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; import { JsonInteractionHandler } from '../JsonInteractionHandler'; import type { WebIdStore } from './util/WebIdStore'; +import type { WebIdLinkRoute } from './WebIdLinkRoute'; /** * Allows users to remove WebIDs linked to their account. */ export class UnlinkWebIdHandler extends JsonInteractionHandler { - private readonly accountStore: AccountStore; private readonly webIdStore: WebIdStore; + private readonly webIdRoute: WebIdLinkRoute; - public constructor(accountStore: AccountStore, webIdStore: WebIdStore) { + public constructor(webIdStore: WebIdStore, webIdRoute: WebIdLinkRoute) { super(); - this.accountStore = accountStore; this.webIdStore = webIdStore; + this.webIdRoute = webIdRoute; } public async handle({ target, accountId }: JsonInteractionHandlerInput): Promise> { - const account = await getRequiredAccount(this.accountStore, accountId); + const match = parsePath(this.webIdRoute, target.path); - const webId = ensureResource(account.webIds, target.path); + const link = await this.webIdStore.get(match.webIdLink); + verifyAccountId(accountId, link?.accountId); - // This also deletes it from the account - await this.webIdStore.delete(webId, account); + await this.webIdStore.delete(match.webIdLink); return { json: {}}; } diff --git a/src/identity/interaction/webid/util/BaseWebIdStore.ts b/src/identity/interaction/webid/util/BaseWebIdStore.ts index c32fb0d79..299c7a3b1 100644 --- a/src/identity/interaction/webid/util/BaseWebIdStore.ts +++ b/src/identity/interaction/webid/util/BaseWebIdStore.ts @@ -1,74 +1,78 @@ -import { createHash } from 'crypto'; +import { Initializer } from '../../../../init/Initializer'; import { getLoggerFor } from '../../../../logging/LogUtil'; -import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage'; import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; -import type { Account } from '../../account/util/Account'; -import type { AccountStore } from '../../account/util/AccountStore'; -import { safeUpdate } from '../../account/util/AccountUtil'; -import type { WebIdLinkRoute } from '../WebIdLinkRoute'; +import { createErrorMessage } from '../../../../util/errors/ErrorUtil'; +import { InternalServerError } from '../../../../util/errors/InternalServerError'; +import { ACCOUNT_TYPE } from '../../account/util/LoginStorage'; +import type { AccountLoginStorage } from '../../account/util/LoginStorage'; import type { WebIdStore } from './WebIdStore'; +const STORAGE_TYPE = 'webIdLink'; +const STORAGE_DESCRIPTION = { + webId: 'string', + accountId: `id:${ACCOUNT_TYPE}`, +} as const; + /** - * A {@link WebIdStore} using a {@link KeyValueStorage} to store the links. - * Keys of the storage are WebIDs, values all the account IDs they are linked to. + * A {@link WebIdStore} using a {@link AccountLoginStorage} to store the links. + * Needs to be initialized before it can be used. */ -export class BaseWebIdStore implements WebIdStore { +export class BaseWebIdStore extends Initializer implements WebIdStore { private readonly logger = getLoggerFor(this); - private readonly webIdRoute: WebIdLinkRoute; - private readonly accountStore: AccountStore; - private readonly storage: KeyValueStorage; + private readonly storage: AccountLoginStorage<{ [STORAGE_TYPE]: typeof STORAGE_DESCRIPTION }>; + private initialized = false; - public constructor(webIdRoute: WebIdLinkRoute, accountStore: AccountStore, - storage: KeyValueStorage) { - this.webIdRoute = webIdRoute; - this.accountStore = accountStore; + public constructor(storage: AccountLoginStorage) { + super(); this.storage = storage; } - public async get(webId: string): Promise { - return await this.storage.get(webId) ?? []; + // Initialize the type definitions + public async handle(): Promise { + if (this.initialized) { + return; + } + try { + await this.storage.defineType(STORAGE_TYPE, STORAGE_DESCRIPTION, false); + await this.storage.createIndex(STORAGE_TYPE, 'accountId'); + await this.storage.createIndex(STORAGE_TYPE, 'webId'); + this.initialized = true; + } catch (cause: unknown) { + throw new InternalServerError(`Error defining WebID links in storage: ${createErrorMessage(cause)}`, + { cause }); + } } - public async add(webId: string, account: Account): Promise { - const accounts = await this.storage.get(webId) ?? []; + public async get(id: string): Promise<{ accountId: string; webId: string } | undefined> { + return this.storage.get(STORAGE_TYPE, id); + } - if (account.webIds[webId]) { - this.logger.warn(`Trying to link WebID ${webId} which is already linked to this account ${account.id}`); + public async isLinked(webId: string, accountId: string): Promise { + const result = await this.storage.find(STORAGE_TYPE, { webId, accountId }); + return result.length > 0; + } + + public async findLinks(accountId: string): Promise<{ id: string; webId: string }[]> { + return (await this.storage.find(STORAGE_TYPE, { accountId })) + .map(({ id, webId }): { id: string; webId: string } => ({ id, webId })); + } + + public async create(webId: string, accountId: string): Promise { + if (await this.isLinked(webId, accountId)) { + this.logger.warn(`Trying to link WebID ${webId} which is already linked to this account ${accountId}`); throw new BadRequestHttpError(`${webId} is already registered to this account.`); } - if (!accounts.includes(account.id)) { - accounts.push(account.id); - } + const result = await this.storage.create(STORAGE_TYPE, { webId, accountId }); - const webIdLink = createHash('sha256').update(webId).digest('hex'); - const resource = this.webIdRoute.getPath({ accountId: account.id, webIdLink }); - account.webIds[webId] = resource; + this.logger.debug(`Linked WebID ${webId} to account ${accountId}`); - await safeUpdate(account, - this.accountStore, - async(): Promise => this.storage.set(webId, accounts)); - - this.logger.debug(`Linked WebID ${webId} to account ${account.id}`); - - return resource; + return result.id; } - public async delete(webId: string, account: Account): Promise { - let accounts = await this.storage.get(webId) ?? []; - - if (accounts.includes(account.id)) { - accounts = accounts.filter((id): boolean => id !== account.id); - delete account.webIds[webId]; - - await safeUpdate(account, - this.accountStore, - async(): Promise => accounts.length === 0 ? - this.storage.delete(webId) : - this.storage.set(webId, accounts)); - - this.logger.debug(`Deleted WebID ${webId} from account ${account.id}`); - } + public async delete(linkId: string): Promise { + this.logger.debug(`Deleting WebID link with ID ${linkId}`); + return this.storage.delete(STORAGE_TYPE, linkId); } } diff --git a/src/identity/interaction/webid/util/WebIdStore.ts b/src/identity/interaction/webid/util/WebIdStore.ts index 01be9916a..7178de802 100644 --- a/src/identity/interaction/webid/util/WebIdStore.ts +++ b/src/identity/interaction/webid/util/WebIdStore.ts @@ -1,31 +1,43 @@ -import type { Account } from '../../account/util/Account'; - /** * Stores and updates WebID to Account links. */ export interface WebIdStore { /** - * Finds all account IDs that are linked to the given WebID. + * Finds the account and WebID of the link with the given ID. * - * @param webId - WebID to find account IDs for. + * @param webId - ID of the link. */ - get: (webId: string) => Promise; + get: (linkId: string) => Promise<{ accountId: string; webId: string } | undefined>; + /** - * Adds the given account ID to the WebID. - * Updates the account accordingly. + * Determines if a WebID is linked to an account. * - * @param webId - WebID to link to. - * @param account - Account to link to the WebID. Will be updated in place. - * - * @returns The resource corresponding to the created link for this account. + * @param webId - WebID to check. + * @param accountId - ID of the account. */ - add: (webId: string, account: Account) => Promise; + isLinked: (webId: string, accountId: string) => Promise; + /** - * Deletes the link between the given WebID and account. - * Updates the account accordingly. + * Finds all links associated with the given account. * - * @param webId - WebID to remove the link from. - * @param account - Account to unlink from the WebID. Will be updated in place. + * @param accountId - ID of the account. */ - delete: (webId: string, account: Account) => Promise; + findLinks: (accountId: string) => Promise<{ id: string; webId: string }[]>; + + /** + * Creates a new WebID link for the given WebID and account. + * + * @param webId - WebID to link. + * @param account - ID of the account to link the WebID to. + * + * @returns ID of the link. + */ + create: (webId: string, accountId: string) => Promise; + + /** + * Deletes the link with the given ID + * + * @param linkId - ID of the link. + */ + delete: (linkId: string) => Promise; } diff --git a/src/index.ts b/src/index.ts index 8b4ab83d6..fc3a996bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -148,16 +148,16 @@ export * from './identity/configuration/PromptFactory'; export * from './identity/configuration/ProviderFactory'; // Identity/Interaction/Account/Util -export * from './identity/interaction/account/util/Account'; export * from './identity/interaction/account/util/AccountUtil'; export * from './identity/interaction/account/util/AccountStore'; export * from './identity/interaction/account/util/BaseAccountStore'; export * from './identity/interaction/account/util/BaseCookieStore'; +export * from './identity/interaction/account/util/BaseLoginAccountStorage'; export * from './identity/interaction/account/util/CookieStore'; +export * from './identity/interaction/account/util/LoginStorage'; // Identity/Interaction/Account export * from './identity/interaction/account/AccountIdRoute'; -export * from './identity/interaction/account/AccountDetailsHandler'; export * from './identity/interaction/account/CreateAccountHandler'; // Identity/Interaction/Client-Credentials/Util diff --git a/templates/identity/account/create-client-credentials.html.ejs b/templates/identity/account/create-client-credentials.html.ejs index 65768172e..0db39c698 100644 --- a/templates/identity/account/create-client-credentials.html.ejs +++ b/templates/identity/account/create-client-credentials.html.ejs @@ -41,13 +41,13 @@ diff --git a/test/integration/Accounts.test.ts b/test/integration/Accounts.test.ts index 64d7f437a..848b1111d 100644 --- a/test/integration/Accounts.test.ts +++ b/test/integration/Accounts.test.ts @@ -1,6 +1,8 @@ import fetch from 'cross-fetch'; import { parse, splitCookiesString } from 'set-cookie-parser'; +import { BasicRepresentation } from '../../src/http/representation/BasicRepresentation'; import type { App } from '../../src/init/App'; +import type { ResourceStore } from '../../src/storage/ResourceStore'; import { APPLICATION_X_WWW_FORM_URLENCODED } from '../../src/util/ContentTypes'; import { joinUrl } from '../../src/util/PathUtil'; import { getPort } from '../util/Util'; @@ -14,15 +16,17 @@ jest.mock('nodemailer'); describe('A server with account management', (): void => { let app: App; + let store: ResourceStore; let sendMail: jest.Mock; + const publicContainer = joinUrl(baseUrl, '/public/'); let cookie: string; const email = 'test@example.com'; let password = 'secret'; const indexUrl = joinUrl(baseUrl, '.account/'); let controls: { main: Record<'index' | 'logins', string>; - account: Record<'create' | 'account' | 'logout' | 'pod' | 'webId' | 'clientCredentials', string>; + account: Record<'create' | 'logout' | 'pod' | 'webId' | 'clientCredentials', string>; password: Record<'login' | 'forgot' | 'create', string>; }; let passwordResource: string; @@ -37,12 +41,27 @@ describe('A server with account management', (): void => { const instances = await instantiateFromConfig( 'urn:solid-server:test:Instances', - getTestConfigPath('server-memory.json'), + getTestConfigPath('memory-pod.json'), getDefaultVariables(port, baseUrl), ) as Record; - ({ app } = instances); + ({ app, store } = instances); await app.start(); + // Create a public container where we can write any data + await store.setRepresentation( + { path: joinUrl(publicContainer, '.acl') }, + new BasicRepresentation(` + @prefix acl: . + @prefix foaf: . + <#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./>; + acl:default <./>; + acl:mode acl:Read, acl:Write, acl:Control.`, + 'text/turtle'), + ); + controls = { main: {}, account: {}, login: {}, password: {}} as any; }); @@ -75,28 +94,25 @@ describe('A server with account management', (): void => { const res = await fetch(controls.account.create, { method: 'POST' }); expect(res.status).toBe(200); const json = await res.json(); - expect(json.resource).toBeDefined(); expect(res.headers.get('set-cookie')).toBeDefined(); const cookies = parse(splitCookiesString(res.headers.get('set-cookie')!)); expect(cookies).toHaveLength(1); cookie = `${cookies[0].name}=${cookies[0].value}`; expect(json.cookie).toBe(cookies[0].value); - - controls.account.account = json.resource; }); - it('can access the account using the cookie.', async(): Promise => { - expect((await fetch(controls.account.account)).status).toBe(401); - const res = await fetch(controls.account.account, { headers: { cookie }}); + it('can only access the account controls the cookie.', async(): Promise => { + const res = await fetch(indexUrl, { headers: { cookie }}); expect(res.status).toBe(200); const json = await res.json(); - expect(json.controls.account.account).toEqual(controls.account.account); expect(json.controls.account.logout).toBeDefined(); expect(json.controls.account.pod).toBeDefined(); expect(json.controls.account.webId).toBeDefined(); expect(json.controls.account.clientCredentials).toBeDefined(); + expect((await fetch(json.controls.account.pod)).status).toBe(401); + controls.account = json.controls.account; expect(json.controls.password.create).toBeDefined(); @@ -105,13 +121,12 @@ describe('A server with account management', (): void => { expect(json.controls.html.account).toBeDefined(); }); - it('can also access the account using the custom authorization header.', async(): Promise => { - expect((await fetch(controls.account.account)).status).toBe(401); - const res = await fetch(controls.account.account, { headers: + it('can also access the account controls using the custom authorization header.', async(): Promise => { + const res = await fetch(indexUrl, { headers: { authorization: `CSS-Account-Cookie ${cookie.split('=')[1]}` }}); expect(res.status).toBe(200); const json = await res.json(); - expect(json.controls.account.account).toEqual(controls.account.account); + expect(json.controls.account.pod).toEqual(controls.account.pod); }); it('can not create a pod since the account has no login.', async(): Promise => { @@ -138,10 +153,10 @@ describe('A server with account management', (): void => { expect(json.resource).toBeDefined(); ({ resource: passwordResource } = json); - // Verify if the content was added to the profile - res = await fetch(controls.account.account, { headers: { cookie }}); + // Verify if the content was added to the account + res = await fetch(controls.password.create, { headers: { cookie }}); expect(res.status).toBe(200); - expect((await res.json()).logins.password).toEqual({ [email]: passwordResource }); + expect((await res.json()).passwordLogins).toEqual({ [email]: passwordResource }); }); it('can not delete its last login method.', async(): Promise => { @@ -154,13 +169,13 @@ describe('A server with account management', (): void => { let res = await fetch(controls.account.create, { method: 'POST' }); const cookies = parse(splitCookiesString(res.headers.get('set-cookie')!)); const newCookie = `${cookies[0].name}=${cookies[0].value}`; - const { resource } = await res.json(); - res = await fetch(resource, { headers: { cookie: newCookie }}); - const oldAccount: { controls: typeof controls } = await res.json(); + res = await fetch(indexUrl, { headers: { cookie: newCookie }}); + expect(res.status).toBe(200); + const newControls: typeof controls = (await res.json()).controls; // This will fail because the email address is already used by a different account - res = await fetch(oldAccount.controls.password.create, { + res = await fetch(newControls.password.create, { method: 'POST', headers: { cookie: newCookie, 'content-type': 'application/json' }, body: JSON.stringify({ email, password }), @@ -168,15 +183,15 @@ describe('A server with account management', (): void => { expect(res.status).toBe(400); // Make sure the account still has no login method - res = await fetch(resource, { headers: { cookie: newCookie }}); - await expect(res.json()).resolves.toEqual(oldAccount); + res = await fetch(newControls.password.create, { headers: { cookie: newCookie }}); + expect((await res.json()).passwordLogins).toEqual({}); }); it('can log out.', async(): Promise => { const res = await fetch(controls.account.logout, { method: 'POST', headers: { cookie }}); expect(res.status).toBe(200); // Cookie doesn't work anymore - expect((await fetch(controls.account.account, { headers: { cookie }})).status).toBe(401); + expect((await fetch(controls.account.pod, { headers: { cookie }})).status).toBe(401); }); it('can login again with email/password.', async(): Promise => { @@ -191,7 +206,7 @@ describe('A server with account management', (): void => { expect(cookies).toHaveLength(1); cookie = `${cookies[0].name}=${cookies[0].value}`; // Cookie is valid again - expect((await fetch(controls.account.account, { headers: { cookie }})).status).toBe(200); + expect((await fetch(controls.account.pod, { headers: { cookie }})).status).toBe(200); }); it('can change the password.', async(): Promise => { @@ -223,7 +238,7 @@ describe('A server with account management', (): void => { body: JSON.stringify({ name: 'test' }), }); expect(res.status).toBe(200); - let json = await res.json(); + const json = await res.json(); expect(json.pod).toBeDefined(); expect(json.podResource).toBeDefined(); expect(json.webId).toBeDefined(); @@ -231,18 +246,19 @@ describe('A server with account management', (): void => { ({ pod, webId } = json); // Verify if the content was added to the profile - res = await fetch(controls.account.account, { headers: { cookie }}); + res = await fetch(controls.account.pod, { headers: { cookie }}); expect(res.status).toBe(200); - json = await res.json(); - expect(json.pods[pod]).toBeDefined(); - expect(json.webIds[webId]).toBeDefined(); + expect((await res.json()).pods[pod]).toBeDefined(); + res = await fetch(controls.account.webId, { headers: { cookie }}); + expect(res.status).toBe(200); + expect((await res.json()).webIdLinks[webId]).toBeDefined(); }); it('does not store any data if creating a pod fails on the same account.', async(): Promise => { - let res = await fetch(controls.account.account, { headers: { cookie }}); - const oldAccount = await res.json(); + const oldPods = (await (await fetch(controls.account.pod, { headers: { cookie }})).json()).pods; + const oldWebIdLinks = (await (await fetch(controls.account.webId, { headers: { cookie }})).json()).webIdLinks; - res = await fetch(controls.account.pod, { + const res = await fetch(controls.account.pod, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ name: 'test' }), @@ -250,13 +266,15 @@ describe('A server with account management', (): void => { expect(res.status).toBe(400); // Verify nothing was added - res = await fetch(controls.account.account, { headers: { cookie }}); - await expect(res.json()).resolves.toEqual(oldAccount); + const newPods = (await (await fetch(controls.account.pod, { headers: { cookie }})).json()).pods; + const newWebIdLinks = (await (await fetch(controls.account.webId, { headers: { cookie }})).json()).webIdLinks; + expect(oldPods).toEqual(newPods); + expect(oldWebIdLinks).toEqual(newWebIdLinks); }); it('does not store any data if creating a pod fails on a different account.', async(): Promise => { // We have to create a new account here to try to create a pod with the same name. - // Otherwise the server will never try to write data + // Otherwise, the server will never try to write data // since it would notice the account already has a pod with that name. let res = await fetch(controls.account.create, { method: 'POST' }); const cookies = parse(splitCookiesString(res.headers.get('set-cookie')!)); @@ -273,8 +291,9 @@ describe('A server with account management', (): void => { }); expect(res.status).toBe(200); - res = await fetch(json.controls.account.account, { headers: { cookie: newCookie }}); - const oldAccount = await res.json(); + const oldPods = (await (await fetch(controls.account.pod, { headers: { cookie: newCookie }})).json()).pods; + const oldWebIdLinks = (await (await fetch(controls.account.webId, { headers: { cookie: newCookie }})).json()) + .webIdLinks; // This will fail because there already is a pod with this name res = await fetch(json.controls.account.pod, { @@ -286,17 +305,20 @@ describe('A server with account management', (): void => { expect((await res.json()).message).toContain('Pod creation failed'); // Make sure there is no reference in the account data - res = await fetch(json.controls.account.account, { headers: { cookie: newCookie }}); - await expect(res.json()).resolves.toEqual(oldAccount); + const newPods = (await (await fetch(controls.account.pod, { headers: { cookie: newCookie }})).json()).pods; + const newWebIdLinks = (await (await fetch(controls.account.webId, { headers: { cookie: newCookie }})).json()) + .webIdLinks; + expect(oldPods).toEqual(newPods); + expect(oldWebIdLinks).toEqual(newWebIdLinks); }); it('can remove the WebID link.', async(): Promise => { - let res = await fetch(controls.account.account, { headers: { cookie }}); - const webIdResource = (await res.json()).webIds[webId]; + let res = await fetch(controls.account.webId, { headers: { cookie }}); + const webIdResource = (await res.json()).webIdLinks[webId]; res = await fetch(webIdResource, { method: 'DELETE', headers: { cookie }}); expect(res.status).toBe(200); - res = await fetch(controls.account.account, { headers: { cookie }}); - expect((await res.json()).webIds[webId]).toBeUndefined(); + res = await fetch(controls.account.webId, { headers: { cookie }}); + expect((await res.json()).webIdLinks[webId]).toBeUndefined(); }); it('can link the WebID again.', async(): Promise => { @@ -311,14 +333,14 @@ describe('A server with account management', (): void => { expect(json.oidcIssuer).toBe(baseUrl); // Verify if the content was added to the profile - res = await fetch(controls.account.account, { headers: { cookie }}); + res = await fetch(controls.account.webId, { headers: { cookie }}); expect(res.status).toBe(200); json = await res.json(); - expect(json.webIds[webId]).toBeDefined(); + expect(json.webIdLinks[webId]).toBeDefined(); }); it('needs to prove ownership when linking a WebID outside of a pod.', async(): Promise => { - const otherWebId = joinUrl(baseUrl, 'other#me'); + const otherWebId = joinUrl(publicContainer, 'other#me'); // Create the WebID let res = await fetch(otherWebId, { method: 'PUT', @@ -354,12 +376,12 @@ describe('A server with account management', (): void => { expect(res.status).toBe(200); // Verify if the content was added to the profile - res = await fetch(controls.account.account, { headers: { cookie }}); + res = await fetch(controls.account.webId, { headers: { cookie }}); expect(res.status).toBe(200); json = await res.json(); // 2 linked WebIDs now - expect(json.webIds[webId]).toBeDefined(); - expect(json.webIds[otherWebId]).toBeDefined(); + expect(json.webIdLinks[webId]).toBeDefined(); + expect(json.webIdLinks[otherWebId]).toBeDefined(); }); it('can create a client credentials token.', async(): Promise => { @@ -376,7 +398,7 @@ describe('A server with account management', (): void => { const { id, resource, secret } = json; // Verify if the content was added to the profile - res = await fetch(controls.account.account, { headers: { cookie }}); + res = await fetch(controls.account.clientCredentials, { headers: { cookie }}); expect(res.status).toBe(200); const { clientCredentials } = await res.json(); expect(clientCredentials[id]).toBe(resource); @@ -397,21 +419,21 @@ describe('A server with account management', (): void => { }); it('can remove registered WebIDs.', async(): Promise => { - let res = await fetch(controls.account.account, { headers: { cookie }}); + let res = await fetch(controls.account.webId, { headers: { cookie }}); expect(res.status).toBe(200); let json = await res.json(); - res = await fetch(json.webIds[webId], { method: 'DELETE', headers: { cookie }}); + res = await fetch(json.webIdLinks[webId], { method: 'DELETE', headers: { cookie }}); expect(res.status).toBe(200); // Make sure it's gone - res = await fetch(controls.account.account, { headers: { cookie }}); + res = await fetch(controls.account.webId, { headers: { cookie }}); json = await res.json(); - expect(json.webIds[webId]).toBeUndefined(); + expect(json.webIdLinks[webId]).toBeUndefined(); }); it('can remove credential tokens.', async(): Promise => { - let res = await fetch(controls.account.account, { headers: { cookie }}); + let res = await fetch(controls.account.clientCredentials, { headers: { cookie }}); expect(res.status).toBe(200); let json = await res.json(); @@ -420,7 +442,7 @@ describe('A server with account management', (): void => { expect(res.status).toBe(200); // Make sure it's gone - res = await fetch(controls.account.account, { headers: { cookie }}); + res = await fetch(controls.account.clientCredentials, { headers: { cookie }}); json = await res.json(); expect(Object.keys(json.clientCredentials)).toHaveLength(0); }); diff --git a/test/integration/config/memory-pod.json b/test/integration/config/memory-pod.json new file mode 100644 index 000000000..1062e7690 --- /dev/null +++ b/test/integration/config/memory-pod.json @@ -0,0 +1,51 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "import": [ + "css:config/app/main/default.json", + "css:config/app/init/static-root.json", + "css:config/http/handler/default.json", + "css:config/http/middleware/default.json", + "css:config/http/notifications/all.json", + "css:config/http/server-factory/http.json", + "css:config/http/static/default.json", + "css:config/identity/access/public.json", + "css:config/identity/email/example.json", + "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", + "css:config/identity/ownership/token.json", + "css:config/identity/pod/static.json", + "css:config/ldp/authentication/dpop-bearer.json", + "css:config/ldp/authorization/webacl.json", + "css:config/ldp/handler/default.json", + "css:config/ldp/metadata-parser/default.json", + "css:config/ldp/metadata-writer/default.json", + "css:config/ldp/modes/default.json", + "css:config/storage/backend/memory.json", + "css:config/storage/key-value/resource-store.json", + "css:config/storage/location/pod.json", + "css:config/storage/middleware/default.json", + "css:config/util/auxiliary/acl.json", + "css:config/util/identifiers/suffix.json", + "css:config/util/index/default.json", + "css:config/util/logging/winston.json", + "css:config/util/representation-conversion/default.json", + "css:config/util/resource-locker/memory.json", + "css:config/util/variables/default.json" + ], + "@graph": [ + { + "@id": "urn:solid-server:test:Instances", + "@type": "RecordObject", + "record": [ + { + "RecordObject:_record_key": "app", + "RecordObject:_record_value": { "@id": "urn:solid-server:default:App" } + }, + { + "RecordObject:_record_key": "store", + "RecordObject:_record_value": { "@id": "urn:solid-server:default:ResourceStore_Backend" } + } + ] + } + ] +} diff --git a/test/unit/authorization/OwnerPermissionReader.test.ts b/test/unit/authorization/OwnerPermissionReader.test.ts index c35b6202b..5397051da 100644 --- a/test/unit/authorization/OwnerPermissionReader.test.ts +++ b/test/unit/authorization/OwnerPermissionReader.test.ts @@ -2,27 +2,25 @@ import type { Credentials } from '../../../src/authentication/Credentials'; import { OwnerPermissionReader } from '../../../src/authorization/OwnerPermissionReader'; import { AclMode } from '../../../src/authorization/permissions/AclPermissionSet'; import type { AccessMap } from '../../../src/authorization/permissions/Permissions'; -import type { AuxiliaryIdentifierStrategy } from '../../../src/http/auxiliary/AuxiliaryIdentifierStrategy'; +import { AuxiliaryIdentifierStrategy } from '../../../src/http/auxiliary/AuxiliaryIdentifierStrategy'; import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier'; -import type { Account } from '../../../src/identity/interaction/account/util/Account'; -import type { AccountStore } from '../../../src/identity/interaction/account/util/AccountStore'; -import type { WebIdStore } from '../../../src/identity/interaction/webid/util/WebIdStore'; -import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy'; +import { PodStore } from '../../../src/identity/interaction/pod/util/PodStore'; +import { WebIdStore } from '../../../src/identity/interaction/webid/util/WebIdStore'; +import type { StorageLocationStrategy } from '../../../src/server/description/StorageLocationStrategy'; import { IdentifierMap, IdentifierSetMultiMap } from '../../../src/util/map/IdentifierMap'; -import { createAccount } from '../../util/AccountUtil'; import { compareMaps } from '../../util/Util'; describe('An OwnerPermissionReader', (): void => { const owner = 'http://example.com/alice/profile/card#me'; const podBaseUrl = 'http://example.com/alice/'; + const accountId = 'accountId'; let credentials: Credentials; let identifier: ResourceIdentifier; let requestedModes: AccessMap; - let account: Account; - let accountStore: jest.Mocked; + let podStore: jest.Mocked; let webIdStore: jest.Mocked; let aclStrategy: jest.Mocked; - const identifierStrategy = new SingleRootIdentifierStrategy('http://example.com/'); + let storageStrategy: jest.Mocked; let reader: OwnerPermissionReader; beforeEach(async(): Promise => { @@ -32,23 +30,23 @@ describe('An OwnerPermissionReader', (): void => { requestedModes = new IdentifierSetMultiMap([[ identifier, AclMode.control ]]) as any; - account = createAccount(); - account.pods[podBaseUrl] = 'url'; - account.webIds[owner] = 'url'; + podStore = { + findAccount: jest.fn().mockResolvedValue(accountId), + } satisfies Partial as any; webIdStore = { - get: jest.fn().mockResolvedValue([ account.id ]), - } as any; - - accountStore = { - get: jest.fn().mockResolvedValue(account), - } as any; + findLinks: jest.fn().mockResolvedValue([{ id: '???', webId: owner }]), + } satisfies Partial as any; aclStrategy = { isAuxiliaryIdentifier: jest.fn((id): boolean => id.path.endsWith('.acl')), - } as any; + } satisfies Partial as any; - reader = new OwnerPermissionReader(webIdStore, accountStore, aclStrategy, identifierStrategy); + storageStrategy = { + getStorageIdentifier: jest.fn().mockResolvedValue(podBaseUrl), + }; + + reader = new OwnerPermissionReader(podStore, webIdStore, aclStrategy, storageStrategy); }); it('returns empty permissions for non-ACL resources.', async(): Promise => { @@ -61,23 +59,18 @@ describe('An OwnerPermissionReader', (): void => { compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap()); }); - it('returns empty permissions if the agent has no account.', async(): Promise => { - webIdStore.get.mockResolvedValueOnce([]); + it('returns empty permissions if no root storage could be determined.', async(): Promise => { + storageStrategy.getStorageIdentifier.mockRejectedValueOnce(new Error('no root!')); compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap()); }); - it('returns empty permissions if no account was found for the stored ID.', async(): Promise => { - accountStore.get.mockResolvedValueOnce(undefined); + it('returns empty permissions if there is no pod owner.', async(): Promise => { + podStore.findAccount.mockResolvedValueOnce(undefined); compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap()); }); - it('returns empty permissions if the account has no pod.', async(): Promise => { - delete account.pods[podBaseUrl]; - compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap()); - }); - - it('returns empty permissions if the target identifier is not in the pod.', async(): Promise => { - identifier.path = 'http://somewhere.else/.acl'; + it('returns empty permissions if the agent WebID is not linked to the owner account.', async(): Promise => { + credentials.agent!.webId = 'http://example.com/otherWebId'; compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap()); }); diff --git a/test/unit/identity/configuration/AccountPromptFactory.test.ts b/test/unit/identity/configuration/AccountPromptFactory.test.ts index 142852466..4388e95a6 100644 --- a/test/unit/identity/configuration/AccountPromptFactory.test.ts +++ b/test/unit/identity/configuration/AccountPromptFactory.test.ts @@ -1,10 +1,8 @@ import { interactionPolicy } from 'oidc-provider'; import type { KoaContextWithOIDC } from 'oidc-provider'; import { AccountPromptFactory } from '../../../../src/identity/configuration/AccountPromptFactory'; -import type { Account } from '../../../../src/identity/interaction/account/util/Account'; -import type { AccountStore } from '../../../../src/identity/interaction/account/util/AccountStore'; -import type { CookieStore } from '../../../../src/identity/interaction/account/util/CookieStore'; -import { createAccount, mockAccountStore } from '../../../util/AccountUtil'; +import { CookieStore } from '../../../../src/identity/interaction/account/util/CookieStore'; +import { WebIdStore } from '../../../../src/identity/interaction/webid/util/WebIdStore'; import DefaultPolicy = interactionPolicy.DefaultPolicy; import Prompt = interactionPolicy.Prompt; @@ -12,10 +10,9 @@ describe('An AccountPromptFactory', (): void => { let ctx: KoaContextWithOIDC; let policy: jest.Mocked; const webId = 'http://example.com/card#me'; - let account: Account; const accountId = 'id'; const name = 'name'; - let accountStore: jest.Mocked; + let webIdStore: jest.Mocked; let cookieStore: jest.Mocked; let factory: AccountPromptFactory; @@ -36,18 +33,15 @@ describe('An AccountPromptFactory', (): void => { }, } as any; - account = createAccount(); - account.webIds[webId] = 'resource'; - accountStore = mockAccountStore(account); + webIdStore = { + isLinked: jest.fn().mockResolvedValue(true), + } satisfies Partial as any; cookieStore = { - generate: jest.fn(), get: jest.fn().mockResolvedValue(accountId), - delete: jest.fn(), - refresh: jest.fn(), - }; + } satisfies Partial as any; - factory = new AccountPromptFactory(accountStore, cookieStore, name); + factory = new AccountPromptFactory(webIdStore, cookieStore, name); }); describe('account prompt', (): void => { @@ -91,7 +85,7 @@ describe('An AccountPromptFactory', (): void => { }); it('triggers if the account does not own the WebID.', async(): Promise => { - delete account.webIds[webId]; + webIdStore.isLinked.mockResolvedValueOnce(false); await expect(factory.handle(policy)).resolves.toBeUndefined(); const prompt = policy.get.mock.results[0].value; const check = prompt.checks[0]; @@ -114,14 +108,6 @@ describe('An AccountPromptFactory', (): void => { await expect(check.check(ctx)).resolves.toBe(false); }); - it('does not trigger if no account was found.', async(): Promise => { - accountStore.get.mockResolvedValue(undefined); - await expect(factory.handle(policy)).resolves.toBeUndefined(); - const prompt = policy.get.mock.results[0].value; - const check = prompt.checks[0]; - await expect(check.check(ctx)).resolves.toBe(false); - }); - it('errors if the login prompt could not be found.', async(): Promise => { policy.get.mockReturnValue(undefined); await expect(factory.handle(policy)).rejects.toThrow('Missing default login policy'); diff --git a/test/unit/identity/configuration/IdentityProviderFactory.test.ts b/test/unit/identity/configuration/IdentityProviderFactory.test.ts index 45aba1310..88a2e2dcf 100644 --- a/test/unit/identity/configuration/IdentityProviderFactory.test.ts +++ b/test/unit/identity/configuration/IdentityProviderFactory.test.ts @@ -1,18 +1,18 @@ import { Readable } from 'stream'; import { exportJWK, generateKeyPair } from 'jose'; import type * as Koa from 'koa'; -import type { ErrorHandler } from '../../../../src/http/output/error/ErrorHandler'; -import type { ResponseWriter } from '../../../../src/http/output/ResponseWriter'; +import { ErrorHandler } from '../../../../src/http/output/error/ErrorHandler'; +import { ResponseWriter } from '../../../../src/http/output/ResponseWriter'; import { IdentityProviderFactory } from '../../../../src/identity/configuration/IdentityProviderFactory'; import type { JwkGenerator } from '../../../../src/identity/configuration/JwkGenerator'; -import type { PromptFactory } from '../../../../src/identity/configuration/PromptFactory'; -import type { +import { PromptFactory } from '../../../../src/identity/configuration/PromptFactory'; +import { ClientCredentialsStore, } from '../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore'; import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler'; import type { InteractionRoute } from '../../../../src/identity/interaction/routing/InteractionRoute'; import type { AdapterFactory } from '../../../../src/identity/storage/AdapterFactory'; -import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage'; +import { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage'; import { extractErrorTerms } from '../../../../src/util/errors/HttpErrorUtil'; import { OAuthHttpError } from '../../../../src/util/errors/OAuthHttpError'; import type { errors, Configuration, KoaContextWithOIDC } from '../../../../templates/types/oidc-provider'; @@ -101,7 +101,7 @@ describe('An IdentityProviderFactory', (): void => { promptFactory = { handleSafe: jest.fn(), - } as any; + } satisfies Partial as any; adapterFactory = { createStorageAdapter: jest.fn().mockReturnValue('adapter!'), @@ -111,7 +111,7 @@ describe('An IdentityProviderFactory', (): void => { storage = { get: jest.fn((id: string): any => map.get(id)), set: jest.fn((id: string, value: any): any => map.set(id, value)), - } as any; + } satisfies Partial> as any; const { privateKey, publicKey } = await generateKeyPair('ES256'); jwkGenerator = { @@ -121,14 +121,14 @@ describe('An IdentityProviderFactory', (): void => { }; clientCredentialsStore = { - get: jest.fn(), - } as any; + findByLabel: jest.fn(), + } satisfies Partial as any; errorHandler = { handleSafe: jest.fn().mockResolvedValue({ statusCode: 500 }), - } as any; + } satisfies Partial as any; - responseWriter = { handleSafe: jest.fn() } as any; + responseWriter = { handleSafe: jest.fn() } satisfies Partial as any; factory = new IdentityProviderFactory(baseConfig, { promptFactory, @@ -174,10 +174,12 @@ describe('An IdentityProviderFactory', (): void => { findResult = await config.findAccount?.({ oidc: {}} as any, webId); await expect((findResult?.claims as any)()).resolves.toEqual({ sub: webId, webid: webId }); - await expect((config.extraTokenClaims as any)({}, {})).resolves.toEqual({}); - const client = { clientId: 'my_id' }; - await expect((config.extraTokenClaims as any)({}, { client })).resolves.toEqual({}); - clientCredentialsStore.get.mockResolvedValueOnce({ accountId: 'id', webId: 'http://example.com/foo', secret: 'my-secret' }); + await expect((config.extraTokenClaims as any)({}, {})) + .rejects.toThrow('Missing client ID from client credentials.'); + const client = { clientId: 'my_id', kind: 'ClientCredentials' }; + await expect((config.extraTokenClaims as any)({}, { client })) + .rejects.toThrow(`Unknown client credentials token my_id`); + clientCredentialsStore.findByLabel.mockResolvedValueOnce({ id: 'id', label: 'label', accountId: 'id', webId: 'http://example.com/foo', secret: 'my-secret' }); await expect((config.extraTokenClaims as any)({}, { client })) .resolves.toEqual({ webid: 'http://example.com/foo' }); await expect((config.extraTokenClaims as any)({}, { kind: 'AccessToken', accountId: webId, clientId: 'clientId' })) diff --git a/test/unit/identity/interaction/CookieInteractionHandler.test.ts b/test/unit/identity/interaction/CookieInteractionHandler.test.ts index eaabe6859..bd55d7f01 100644 --- a/test/unit/identity/interaction/CookieInteractionHandler.test.ts +++ b/test/unit/identity/interaction/CookieInteractionHandler.test.ts @@ -1,9 +1,10 @@ import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; -import { ACCOUNT_SETTINGS_REMEMBER_LOGIN } from '../../../../src/identity/interaction/account/util/Account'; -import type { Account } from '../../../../src/identity/interaction/account/util/Account'; -import type { AccountStore } from '../../../../src/identity/interaction/account/util/AccountStore'; -import type { CookieStore } from '../../../../src/identity/interaction/account/util/CookieStore'; +import { + ACCOUNT_SETTINGS_REMEMBER_LOGIN, + AccountStore, +} from '../../../../src/identity/interaction/account/util/AccountStore'; +import { CookieStore } from '../../../../src/identity/interaction/account/util/CookieStore'; import { CookieInteractionHandler } from '../../../../src/identity/interaction/CookieInteractionHandler'; import type { JsonRepresentation } from '../../../../src/identity/interaction/InteractionUtil'; import type { @@ -11,7 +12,6 @@ import type { JsonInteractionHandlerInput, } from '../../../../src/identity/interaction/JsonInteractionHandler'; import { SOLID_HTTP } from '../../../../src/util/Vocabularies'; -import { createAccount, mockAccountStore } from '../../../util/AccountUtil'; describe('A CookieInteractionHandler', (): void => { const date = new Date(); @@ -20,7 +20,6 @@ describe('A CookieInteractionHandler', (): void => { const target: ResourceIdentifier = { path: 'http://example.com/foo' }; let input: JsonInteractionHandlerInput; let output: JsonRepresentation; - let account: Account; let source: jest.Mocked; let accountStore: jest.Mocked; let cookieStore: jest.Mocked; @@ -39,14 +38,14 @@ describe('A CookieInteractionHandler', (): void => { metadata: new RepresentationMetadata(), }; - account = createAccount(accountId); - account.settings[ACCOUNT_SETTINGS_REMEMBER_LOGIN] = true; - accountStore = mockAccountStore(account); + accountStore = { + getSetting: jest.fn().mockResolvedValue(true), + } satisfies Partial as any; cookieStore = { - get: jest.fn().mockResolvedValue(account.id), + get: jest.fn().mockResolvedValue(accountId), refresh: jest.fn().mockResolvedValue(date), - } as any; + } satisfies Partial as any; source = { canHandle: jest.fn(), @@ -73,8 +72,8 @@ describe('A CookieInteractionHandler', (): void => { expect(source.handle).toHaveBeenLastCalledWith(input); expect(cookieStore.get).toHaveBeenCalledTimes(1); expect(cookieStore.get).toHaveBeenLastCalledWith(cookie); - expect(accountStore.get).toHaveBeenCalledTimes(1); - expect(accountStore.get).toHaveBeenLastCalledWith(accountId); + expect(accountStore.getSetting).toHaveBeenCalledTimes(1); + expect(accountStore.getSetting).toHaveBeenLastCalledWith(accountId, ACCOUNT_SETTINGS_REMEMBER_LOGIN); expect(cookieStore.refresh).toHaveBeenCalledTimes(1); expect(cookieStore.refresh).toHaveBeenLastCalledWith(cookie); expect(output.metadata?.get(SOLID_HTTP.terms.accountCookie)?.value).toBe(cookie); @@ -88,8 +87,8 @@ describe('A CookieInteractionHandler', (): void => { expect(source.handle).toHaveBeenLastCalledWith(input); expect(cookieStore.get).toHaveBeenCalledTimes(1); expect(cookieStore.get).toHaveBeenLastCalledWith(cookie); - expect(accountStore.get).toHaveBeenCalledTimes(1); - expect(accountStore.get).toHaveBeenLastCalledWith(accountId); + expect(accountStore.getSetting).toHaveBeenCalledTimes(1); + expect(accountStore.getSetting).toHaveBeenLastCalledWith(accountId, ACCOUNT_SETTINGS_REMEMBER_LOGIN); expect(cookieStore.refresh).toHaveBeenCalledTimes(1); expect(cookieStore.refresh).toHaveBeenLastCalledWith(cookie); // Typescript things the typing of this is `never` since we deleted it above @@ -108,7 +107,7 @@ describe('A CookieInteractionHandler', (): void => { input.metadata.removeAll(SOLID_HTTP.terms.accountCookie); await expect(handler.handle(input)).resolves.toEqual(output); expect(cookieStore.get).toHaveBeenCalledTimes(0); - expect(accountStore.get).toHaveBeenCalledTimes(0); + expect(accountStore.getSetting).toHaveBeenCalledTimes(0); expect(cookieStore.refresh).toHaveBeenCalledTimes(0); expect(output.metadata?.get(SOLID_HTTP.terms.accountCookie)).toBeUndefined(); expect(output.metadata?.get(SOLID_HTTP.terms.accountCookieExpiration)).toBeUndefined(); @@ -119,7 +118,7 @@ describe('A CookieInteractionHandler', (): void => { output.metadata!.set(SOLID_HTTP.terms.accountCookieExpiration, date.toISOString()); await expect(handler.handle(input)).resolves.toEqual(output); expect(cookieStore.get).toHaveBeenCalledTimes(0); - expect(accountStore.get).toHaveBeenCalledTimes(0); + expect(accountStore.getSetting).toHaveBeenCalledTimes(0); expect(cookieStore.refresh).toHaveBeenCalledTimes(0); }); @@ -128,19 +127,19 @@ describe('A CookieInteractionHandler', (): void => { await expect(handler.handle(input)).resolves.toEqual(output); expect(cookieStore.get).toHaveBeenCalledTimes(1); expect(cookieStore.get).toHaveBeenLastCalledWith(cookie); - expect(accountStore.get).toHaveBeenCalledTimes(0); + expect(accountStore.getSetting).toHaveBeenCalledTimes(0); expect(cookieStore.refresh).toHaveBeenCalledTimes(0); expect(output.metadata?.get(SOLID_HTTP.terms.accountCookie)).toBeUndefined(); expect(output.metadata?.get(SOLID_HTTP.terms.accountCookieExpiration)).toBeUndefined(); }); it('adds no cookie metadata if the account does not want to be remembered.', async(): Promise => { - account.settings[ACCOUNT_SETTINGS_REMEMBER_LOGIN] = false; + accountStore.getSetting.mockResolvedValueOnce(false); await expect(handler.handle(input)).resolves.toEqual(output); expect(cookieStore.get).toHaveBeenCalledTimes(1); expect(cookieStore.get).toHaveBeenLastCalledWith(cookie); - expect(accountStore.get).toHaveBeenCalledTimes(1); - expect(accountStore.get).toHaveBeenLastCalledWith(accountId); + expect(accountStore.getSetting).toHaveBeenCalledTimes(1); + expect(accountStore.getSetting).toHaveBeenLastCalledWith(accountId, ACCOUNT_SETTINGS_REMEMBER_LOGIN); expect(cookieStore.refresh).toHaveBeenCalledTimes(0); expect(output.metadata?.get(SOLID_HTTP.terms.accountCookie)).toBeUndefined(); expect(output.metadata?.get(SOLID_HTTP.terms.accountCookieExpiration)).toBeUndefined(); @@ -151,8 +150,8 @@ describe('A CookieInteractionHandler', (): void => { await expect(handler.handle(input)).resolves.toEqual(output); expect(cookieStore.get).toHaveBeenCalledTimes(1); expect(cookieStore.get).toHaveBeenLastCalledWith(cookie); - expect(accountStore.get).toHaveBeenCalledTimes(1); - expect(accountStore.get).toHaveBeenLastCalledWith(accountId); + expect(accountStore.getSetting).toHaveBeenCalledTimes(1); + expect(accountStore.getSetting).toHaveBeenLastCalledWith(accountId, ACCOUNT_SETTINGS_REMEMBER_LOGIN); expect(cookieStore.refresh).toHaveBeenCalledTimes(1); expect(cookieStore.refresh).toHaveBeenLastCalledWith(cookie); expect(output.metadata?.get(SOLID_HTTP.terms.accountCookie)).toBeUndefined(); diff --git a/test/unit/identity/interaction/account/AccountDetailsHandler.test.ts b/test/unit/identity/interaction/account/AccountDetailsHandler.test.ts deleted file mode 100644 index 8a1427b5f..000000000 --- a/test/unit/identity/interaction/account/AccountDetailsHandler.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { AccountDetailsHandler } from '../../../../../src/identity/interaction/account/AccountDetailsHandler'; -import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; -import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; - -describe('An AccountDetailsHandler', (): void => { - const accountId = 'id'; - const account = createAccount(); - let accountStore: jest.Mocked; - let handler: AccountDetailsHandler; - - beforeEach(async(): Promise => { - accountStore = mockAccountStore(account); - - handler = new AccountDetailsHandler(accountStore); - }); - - it('returns a JSON representation of the account.', async(): Promise => { - await expect(handler.handle({ accountId } as any)).resolves.toEqual({ json: account }); - }); -}); diff --git a/test/unit/identity/interaction/account/CreateAccountHandler.test.ts b/test/unit/identity/interaction/account/CreateAccountHandler.test.ts index c4f5da979..0974cf7d0 100644 --- a/test/unit/identity/interaction/account/CreateAccountHandler.test.ts +++ b/test/unit/identity/interaction/account/CreateAccountHandler.test.ts @@ -1,14 +1,17 @@ import { CreateAccountHandler } from '../../../../../src/identity/interaction/account/CreateAccountHandler'; -import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; -import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; +import { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; describe('A CreateAccountHandler', (): void => { + const accountId = 'accountId'; let accountStore: jest.Mocked; let handler: CreateAccountHandler; beforeEach(async(): Promise => { - accountStore = mockAccountStore(); - handler = new CreateAccountHandler(accountStore, {} as any, {} as any); + accountStore = { + create: jest.fn().mockResolvedValue(accountId), + } satisfies Partial as any; + + handler = new CreateAccountHandler(accountStore, {} as any); }); it('has no requirements.', async(): Promise => { @@ -16,8 +19,7 @@ describe('A CreateAccountHandler', (): void => { }); it('returns the identifier of the newly created account.', async(): Promise => { - const account = createAccount('custom'); - accountStore.create.mockResolvedValueOnce(account); - await expect(handler.login()).resolves.toEqual({ json: { accountId: 'custom' }}); + await expect(handler.login()).resolves.toEqual({ json: { accountId }}); + expect(accountStore.create).toHaveBeenCalledTimes(1); }); }); diff --git a/test/unit/identity/interaction/account/util/AccountUtil.test.ts b/test/unit/identity/interaction/account/util/AccountUtil.test.ts index d0bce4d20..f381687a4 100644 --- a/test/unit/identity/interaction/account/util/AccountUtil.test.ts +++ b/test/unit/identity/interaction/account/util/AccountUtil.test.ts @@ -1,114 +1,48 @@ -import type { Account } from '../../../../../../src/identity/interaction/account/util/Account'; -import type { AccountStore } from '../../../../../../src/identity/interaction/account/util/AccountStore'; import { - addLoginEntry, - ensureResource, - getRequiredAccount, - safeUpdate, + assertAccountId, parsePath, verifyAccountId, } from '../../../../../../src/identity/interaction/account/util/AccountUtil'; + +import { InteractionRoute } from '../../../../../../src/identity/interaction/routing/InteractionRoute'; +import { InternalServerError } from '../../../../../../src/util/errors/InternalServerError'; import { NotFoundHttpError } from '../../../../../../src/util/errors/NotFoundHttpError'; -import { createAccount, mockAccountStore } from '../../../../../util/AccountUtil'; describe('AccountUtil', (): void => { - const resource = 'http://example.com/.account/link'; - let account: Account; + describe('#assertAccountId', (): void => { + it('does nothing if the accountId is defined.', async(): Promise => { + expect(assertAccountId('id')).toBeUndefined(); + }); - beforeEach(async(): Promise => { - account = createAccount(); + it('throws an error if the accountId is undefined.', async(): Promise => { + expect((): void => assertAccountId()).toThrow(NotFoundHttpError); + }); }); - describe('#getRequiredAccount', (): void => { - let accountStore: jest.Mocked; + describe('#parsePath', (): void => { + let route: jest.Mocked>; beforeEach(async(): Promise => { - accountStore = mockAccountStore(account); + route = { + matchPath: jest.fn().mockReturnValue({ key: 'value' }), + } satisfies Partial> as any; }); - it('returns the found account.', async(): Promise => { - await expect(getRequiredAccount(accountStore, 'id')).resolves.toBe(account); - expect(accountStore.get).toHaveBeenCalledTimes(1); - expect(accountStore.get).toHaveBeenLastCalledWith('id'); + it('returns the matching values.', async(): Promise => { + expect(parsePath(route, 'http://example.com/')).toEqual({ key: 'value' }); }); - it('throws an error if no account was found.', async(): Promise => { - accountStore.get.mockResolvedValueOnce(undefined); - await expect(getRequiredAccount(accountStore)).rejects.toThrow(NotFoundHttpError); + it('errors if the key was not found.', async(): Promise => { + route.matchPath.mockReturnValue(undefined); + expect((): any => parsePath(route, 'http://example.com')).toThrow(InternalServerError); }); }); - describe('#ensureResource', (): void => { - const data = { - 'http://example.com/pod/': resource, - 'http://example.com/other-pod/': 'http://example.com/.account/other-link', - }; - - it('returns the matching key.', async(): Promise => { - expect(ensureResource(data, resource)).toBe('http://example.com/pod/'); + describe('#verifyAccountId', (): void => { + it('does nothing if the values match.', async(): Promise => { + expect(verifyAccountId('id', 'id')).toBeUndefined(); }); - it('throws a 404 if there is no input.', async(): Promise => { - expect((): any => ensureResource(undefined, resource)).toThrow(NotFoundHttpError); - expect((): any => ensureResource(data)).toThrow(NotFoundHttpError); - }); - - it('throws a 404 if there is no match.', async(): Promise => { - expect((): any => ensureResource(data, 'http://example.com/unknown/')).toThrow(NotFoundHttpError); - }); - }); - - describe('#addLoginEntry', (): void => { - it('adds the login entry.', async(): Promise => { - addLoginEntry(account, 'method', 'key', 'resource'); - expect(account.logins?.method?.key).toBe('resource'); - }); - - it('does not overwrite existing entries.', async(): Promise => { - account.logins.method = { key: 'resource' }; - addLoginEntry(account, 'method', 'key2', 'resource2'); - expect(account.logins?.method).toEqual({ key: 'resource', key2: 'resource2' }); - }); - }); - - describe('#safeUpdate', (): void => { - const oldAccount: Account = createAccount(); - let accountStore: jest.Mocked; - let operation: jest.Mock, []>; - - beforeEach(async(): Promise => { - accountStore = mockAccountStore(oldAccount); - - operation = jest.fn().mockResolvedValue('response'); - }); - - it('updates the account and calls the operation function.', async(): Promise => { - account.pods['http://example.com.pod'] = resource; - await expect(safeUpdate(account, accountStore, operation)).resolves.toBe('response'); - expect(accountStore.get).toHaveBeenCalledTimes(1); - expect(accountStore.get).toHaveBeenLastCalledWith(account.id); - expect(accountStore.update).toHaveBeenCalledTimes(1); - expect(accountStore.update).toHaveBeenLastCalledWith(account); - expect(operation).toHaveBeenCalledTimes(1); - expect(account.pods['http://example.com.pod']).toBe(resource); - }); - - it('resets the account data if an error occurs.', async(): Promise => { - const error = new Error('bad data'); - operation.mockRejectedValueOnce(error); - await expect(safeUpdate(account, accountStore, operation)).rejects.toThrow(error); - expect(accountStore.get).toHaveBeenCalledTimes(1); - expect(accountStore.get).toHaveBeenLastCalledWith(account.id); - expect(accountStore.update).toHaveBeenCalledTimes(2); - expect(accountStore.update).toHaveBeenNthCalledWith(1, account); - expect(accountStore.update).toHaveBeenNthCalledWith(2, oldAccount); - expect(operation).toHaveBeenCalledTimes(1); - expect(account.pods).toEqual({}); - }); - - it('throws a 404 if the account is unknown.', async(): Promise => { - accountStore.get.mockResolvedValueOnce(undefined); - await expect(safeUpdate(account, accountStore, operation)).rejects.toThrow(NotFoundHttpError); - expect(accountStore.update).toHaveBeenCalledTimes(0); - expect(operation).toHaveBeenCalledTimes(0); + it('throws an error if the values do not match.', async(): Promise => { + expect((): void => verifyAccountId('id', 'otherId')).toThrow(NotFoundHttpError); }); }); }); diff --git a/test/unit/identity/interaction/account/util/BaseAccountStore.test.ts b/test/unit/identity/interaction/account/util/BaseAccountStore.test.ts index 02633089f..edfdf82fa 100644 --- a/test/unit/identity/interaction/account/util/BaseAccountStore.test.ts +++ b/test/unit/identity/interaction/account/util/BaseAccountStore.test.ts @@ -1,54 +1,70 @@ -import type { Account } from '../../../../../../src/identity/interaction/account/util/Account'; +import { ACCOUNT_SETTINGS_REMEMBER_LOGIN } from '../../../../../../src/identity/interaction/account/util/AccountStore'; import { BaseAccountStore } from '../../../../../../src/identity/interaction/account/util/BaseAccountStore'; -import type { ExpiringStorage } from '../../../../../../src/storage/keyvalue/ExpiringStorage'; -import { NotFoundHttpError } from '../../../../../../src/util/errors/NotFoundHttpError'; -import { createAccount } from '../../../../../util/AccountUtil'; +import { + ACCOUNT_TYPE, + AccountLoginStorage, +} from '../../../../../../src/identity/interaction/account/util/LoginStorage'; +import { InternalServerError } from '../../../../../../src/util/errors/InternalServerError'; jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' })); describe('A BaseAccountStore', (): void => { - let account: Account; - let storage: jest.Mocked>; + const id = 'id'; + let storage: jest.Mocked>; let store: BaseAccountStore; beforeEach(async(): Promise => { - account = createAccount('4c9b88c1-7502-4107-bb79-2a3a590c7aa3'); - storage = { - get: jest.fn().mockResolvedValue(account), - set: jest.fn(), - } as any; + defineType: jest.fn().mockResolvedValue({}), + create: jest.fn().mockResolvedValue({ id }), + get: jest.fn().mockResolvedValue({ id, [ACCOUNT_SETTINGS_REMEMBER_LOGIN]: true }), + setField: jest.fn(), + } satisfies Partial> as any; store = new BaseAccountStore(storage); }); + it('defines the account type in the storage.', async(): Promise => { + await expect(store.handle()).resolves.toBeUndefined(); + expect(storage.defineType).toHaveBeenCalledTimes(1); + expect(storage.defineType).toHaveBeenLastCalledWith(ACCOUNT_TYPE, { + [ACCOUNT_SETTINGS_REMEMBER_LOGIN]: 'boolean?', + }, false); + }); + + it('can only initialize once.', async(): Promise => { + await expect(store.handle()).resolves.toBeUndefined(); + await expect(store.handle()).resolves.toBeUndefined(); + expect(storage.defineType).toHaveBeenCalledTimes(1); + }); + + it('throws an error if defining the type goes wrong.', async(): Promise => { + storage.defineType.mockRejectedValueOnce(new Error('bad data')); + await expect(store.handle()).rejects.toThrow(InternalServerError); + }); + it('creates an empty account.', async(): Promise => { - await expect(store.create()).resolves.toEqual(account); - expect(storage.set).toHaveBeenCalledTimes(1); - expect(storage.set).toHaveBeenLastCalledWith(account.id, account, 30 * 60 * 1000); + await expect(store.create()).resolves.toEqual(id); + expect(storage.create).toHaveBeenCalledTimes(1); + expect(storage.create).toHaveBeenLastCalledWith(ACCOUNT_TYPE, {}); }); - it('stores the new data when updating.', async(): Promise => { - // This line is here just for 100% coverage - account.logins.empty = undefined; - account.logins.method = { key: 'value' }; - await expect(store.update(account)).resolves.toBeUndefined(); - expect(storage.set).toHaveBeenCalledTimes(1); - expect(storage.set).toHaveBeenLastCalledWith(account.id, account); - }); - - it('errors when trying to update without login methods.', async(): Promise => { - await expect(store.update(account)).rejects.toThrow('An account needs at least 1 login method.'); + it('can return a setting.', async(): Promise => { + await expect(store.getSetting(id, ACCOUNT_SETTINGS_REMEMBER_LOGIN)).resolves.toBe(true); expect(storage.get).toHaveBeenCalledTimes(1); - expect(storage.get).toHaveBeenLastCalledWith(account.id); - expect(storage.set).toHaveBeenCalledTimes(0); + expect(storage.get).toHaveBeenLastCalledWith(ACCOUNT_TYPE, id); }); - it('throws a 404 if the account is not known when updating.', async(): Promise => { + it('returns undefined if the accountId is invalid.', async(): Promise => { storage.get.mockResolvedValueOnce(undefined); - await expect(store.update(account)).rejects.toThrow(NotFoundHttpError); + await expect(store.getSetting(id, ACCOUNT_SETTINGS_REMEMBER_LOGIN)).resolves.toBeUndefined(); expect(storage.get).toHaveBeenCalledTimes(1); - expect(storage.get).toHaveBeenLastCalledWith(account.id); - expect(storage.set).toHaveBeenCalledTimes(0); + expect(storage.get).toHaveBeenLastCalledWith(ACCOUNT_TYPE, id); + }); + + it('can set the settings.', async(): Promise => { + await expect(store.updateSetting(id, ACCOUNT_SETTINGS_REMEMBER_LOGIN, true)).resolves.toBeUndefined(); + expect(storage.setField).toHaveBeenCalledTimes(1); + expect(storage.setField).toHaveBeenLastCalledWith(ACCOUNT_TYPE, id, ACCOUNT_SETTINGS_REMEMBER_LOGIN, true); }); }); diff --git a/test/unit/identity/interaction/account/util/BaseLoginAccountStorage.test.ts b/test/unit/identity/interaction/account/util/BaseLoginAccountStorage.test.ts new file mode 100644 index 000000000..0b45e752b --- /dev/null +++ b/test/unit/identity/interaction/account/util/BaseLoginAccountStorage.test.ts @@ -0,0 +1,243 @@ +import { + BaseLoginAccountStorage, +} from '../../../../../../src/identity/interaction/account/util/BaseLoginAccountStorage'; +import { ACCOUNT_TYPE } from '../../../../../../src/identity/interaction/account/util/LoginStorage'; +import type { IndexedStorage } from '../../../../../../src/storage/keyvalue/IndexedStorage'; +import { NotFoundHttpError } from '../../../../../../src/util/errors/NotFoundHttpError'; + +jest.useFakeTimers(); + +describe('A BaseLoginAccountStorage', (): void => { + let source: jest.Mocked>; + let storage: BaseLoginAccountStorage; + + beforeEach(async(): Promise => { + source = { + defineType: jest.fn().mockResolvedValue(undefined), + createIndex: jest.fn().mockResolvedValue(undefined), + has: jest.fn(), + get: jest.fn(), + create: jest.fn(async(type, value): Promise => ({ ...value, id: 'id' })), + find: jest.fn(), + findIds: jest.fn(), + set: jest.fn(), + setField: jest.fn(), + delete: jest.fn(), + entries: jest.fn(), + }; + + storage = new BaseLoginAccountStorage(source); + }); + + it('calls the source when defining types.', async(): Promise => { + await expect(storage.defineType('dummy', { test: 'string' }, false)).resolves.toBeUndefined(); + expect(source.defineType).toHaveBeenCalledTimes(1); + expect(source.defineType).toHaveBeenLastCalledWith('dummy', { test: 'string' }); + }); + + it('adds the linkedLoginsCount parameter when defining the account type.', async(): Promise => { + await expect(storage.defineType(ACCOUNT_TYPE, { test: 'string' }, false)).resolves.toBeUndefined(); + expect(source.defineType).toHaveBeenCalledTimes(1); + expect(source.defineType).toHaveBeenLastCalledWith(ACCOUNT_TYPE, { test: 'string', linkedLoginsCount: 'number' }); + }); + + it('calls the source when defining indexes.', async(): Promise => { + await expect(storage.createIndex('dummy', 'key' as any)).resolves.toBeUndefined(); + expect(source.createIndex).toHaveBeenCalledTimes(1); + expect(source.createIndex).toHaveBeenLastCalledWith('dummy', 'key'); + }); + + it('adds a linkedLoginsCount when creating an account.', async(): Promise => { + await expect(storage.create(ACCOUNT_TYPE, { test: 'data' })).resolves.toEqual({ test: 'data', id: 'id' }); + expect(source.create).toHaveBeenCalledTimes(1); + expect(source.create).toHaveBeenLastCalledWith(ACCOUNT_TYPE, { test: 'data', linkedLoginsCount: 0 }); + }); + + it('deletes an account after the set timeout if it has no login methods.', async(): Promise => { + source.get.mockResolvedValueOnce({ id: 'id', linkedLoginsCount: 0 }); + await expect(storage.create(ACCOUNT_TYPE, { test: 'data' })).resolves.toEqual({ test: 'data', id: 'id' }); + expect(source.create).toHaveBeenCalledTimes(1); + expect(source.create).toHaveBeenLastCalledWith(ACCOUNT_TYPE, { test: 'data', linkedLoginsCount: 0 }); + expect(source.delete).toHaveBeenCalledTimes(0); + + await jest.advanceTimersByTimeAsync(30 * 60 * 1000); + + expect(source.delete).toHaveBeenCalledTimes(1); + expect(source.delete).toHaveBeenLastCalledWith(ACCOUNT_TYPE, 'id'); + }); + + it('does not delete an account after the set timeout if it has a login method.', async(): Promise => { + source.get.mockResolvedValueOnce({ id: 'id', linkedLoginsCount: 1 }); + await expect(storage.create(ACCOUNT_TYPE, { test: 'data' })).resolves.toEqual({ test: 'data', id: 'id' }); + expect(source.create).toHaveBeenCalledTimes(1); + expect(source.create).toHaveBeenLastCalledWith(ACCOUNT_TYPE, { test: 'data', linkedLoginsCount: 0 }); + expect(source.delete).toHaveBeenCalledTimes(0); + + await jest.advanceTimersByTimeAsync(30 * 60 * 1000); + + expect(source.delete).toHaveBeenCalledTimes(0); + }); + + it('prevents creating entries if the account has no linked logins.', async(): Promise => { + source.get.mockResolvedValueOnce({ id: 'id', linkedLoginsCount: 0 }); + await storage.defineType('dummy', { test: 'string', account: `id:${ACCOUNT_TYPE}` }, false); + await expect(storage.create('dummy', { test: 'data', account: 'id' })) + .rejects.toThrow('An account needs at least 1 login method.'); + expect(source.get).toHaveBeenCalledTimes(1); + expect(source.get).toHaveBeenLastCalledWith(ACCOUNT_TYPE, 'id'); + expect(source.create).toHaveBeenCalledTimes(0); + }); + + it('can create new login methods if there are no linked logins.', async(): Promise => { + source.get.mockResolvedValueOnce({ id: 'id', linkedLoginsCount: 0 }); + await storage.defineType('dummy', { test: 'string', account: `id:${ACCOUNT_TYPE}` }, true); + await expect(storage.create('dummy', { test: 'data', account: 'id' })) + .resolves.toEqual({ test: 'data', id: 'id', account: 'id' }); + expect(source.get).toHaveBeenCalledTimes(1); + expect(source.get).toHaveBeenLastCalledWith(ACCOUNT_TYPE, 'id'); + expect(source.create).toHaveBeenCalledTimes(1); + expect(source.create).toHaveBeenLastCalledWith('dummy', { test: 'data', account: 'id' }); + }); + + it('can create other entries if there is at least one linked login.', async(): Promise => { + source.get.mockResolvedValueOnce({ id: 'id', linkedLoginsCount: 1 }); + await storage.defineType('dummy', { test: 'string', account: `id:${ACCOUNT_TYPE}` }, false); + await expect(storage.create('dummy', { test: 'data', account: 'id' })) + .resolves.toEqual({ test: 'data', id: 'id', account: 'id' }); + expect(source.get).toHaveBeenCalledTimes(1); + expect(source.get).toHaveBeenLastCalledWith(ACCOUNT_TYPE, 'id'); + expect(source.create).toHaveBeenCalledTimes(1); + expect(source.create).toHaveBeenLastCalledWith('dummy', { test: 'data', account: 'id' }); + }); + + it('throws a 404 if the linked account does not exist.', async(): Promise => { + source.get.mockResolvedValueOnce(undefined); + await storage.defineType('dummy', { test: 'string', account: `id:${ACCOUNT_TYPE}` }, false); + await expect(storage.create('dummy', { test: 'data', account: 'id' })).rejects.toThrow(NotFoundHttpError); + expect(source.get).toHaveBeenCalledTimes(1); + expect(source.get).toHaveBeenLastCalledWith(ACCOUNT_TYPE, 'id'); + expect(source.create).toHaveBeenCalledTimes(0); + }); + + it('calls the source when checking existence.', async(): Promise => { + source.has.mockResolvedValueOnce(true); + await expect(storage.has('dummy', 'id')).resolves.toBe(true); + expect(source.has).toHaveBeenCalledTimes(1); + expect(source.has).toHaveBeenLastCalledWith('dummy', 'id'); + }); + + it('removes the linkedLoginsCount field when getting values.', async(): Promise => { + source.get.mockResolvedValueOnce({ id: 'id', test: 'data', linkedLoginsCount: 1 }); + await expect(storage.get(ACCOUNT_TYPE, 'id')).resolves.toEqual({ id: 'id', test: 'data' }); + expect(source.get).toHaveBeenCalledTimes(1); + expect(source.get).toHaveBeenLastCalledWith(ACCOUNT_TYPE, 'id'); + }); + + it('removes the linkedLoginsCount field when finding values.', async(): Promise => { + source.find.mockResolvedValueOnce([{ id: 'id', test: 'data', linkedLoginsCount: 1 }]); + await expect(storage.find(ACCOUNT_TYPE, { test: 'data' })).resolves.toEqual([{ id: 'id', test: 'data' }]); + expect(source.find).toHaveBeenCalledTimes(1); + expect(source.find).toHaveBeenLastCalledWith(ACCOUNT_TYPE, { test: 'data' }); + }); + + it('calls the source when finding IDs.', async(): Promise => { + source.findIds.mockResolvedValueOnce([ 'id' ]); + await expect(storage.findIds(ACCOUNT_TYPE, { test: 'data' })).resolves.toEqual([ 'id' ]); + expect(source.findIds).toHaveBeenCalledTimes(1); + expect(source.findIds).toHaveBeenLastCalledWith(ACCOUNT_TYPE, { test: 'data' }); + }); + + it('calls the source when setting values.', async(): Promise => { + await expect(storage.set('dummy', { test: 'data' } as any)).resolves.toBeUndefined(); + expect(source.set).toHaveBeenCalledTimes(1); + expect(source.set).toHaveBeenLastCalledWith('dummy', { test: 'data' }); + }); + + it('keeps the linkedLoginsCount when setting account values.', async(): Promise => { + source.get.mockResolvedValueOnce({ id: 'id', test: 'data', linkedLoginsCount: 1 }); + await expect(storage.set(ACCOUNT_TYPE, { test: 'data' } as any)).resolves.toBeUndefined(); + expect(source.set).toHaveBeenCalledTimes(1); + expect(source.set).toHaveBeenLastCalledWith(ACCOUNT_TYPE, { test: 'data', linkedLoginsCount: 1 }); + }); + + it('throws a 404 when trying to set an unknown account.', async(): Promise => { + source.get.mockResolvedValueOnce(undefined); + await expect(storage.set(ACCOUNT_TYPE, { test: 'data' } as any)).rejects.toThrow(NotFoundHttpError); + expect(source.set).toHaveBeenCalledTimes(0); + }); + + it('calls the source when setting specific keys.', async(): Promise => { + await expect(storage.setField('dummy', 'id', 'test', 'data')).resolves.toBeUndefined(); + expect(source.setField).toHaveBeenCalledTimes(1); + expect(source.setField).toHaveBeenLastCalledWith('dummy', 'id', 'test', 'data'); + }); + + it('calls the source when deleting an account.', async(): Promise => { + await expect(storage.delete(ACCOUNT_TYPE, 'id')).resolves.toBeUndefined(); + expect(source.delete).toHaveBeenCalledTimes(1); + expect(source.delete).toHaveBeenLastCalledWith(ACCOUNT_TYPE, 'id'); + }); + + it('prevents deleting login methods if it is the last one.', async(): Promise => { + // Original + source.get.mockResolvedValueOnce({ id: 'dum', account: 'id' }); + // Account + source.get.mockResolvedValueOnce({ id: 'id', linkedLoginsCount: 1 }); + await storage.defineType('dummy', { test: 'string', account: `id:${ACCOUNT_TYPE}` }, true); + await expect(storage.delete('dummy', 'dum')).rejects.toThrow('An account needs at least 1 login method.'); + expect(source.get).toHaveBeenCalledTimes(2); + expect(source.get).toHaveBeenNthCalledWith(1, 'dummy', 'dum'); + expect(source.get).toHaveBeenNthCalledWith(2, ACCOUNT_TYPE, 'id'); + expect(source.delete).toHaveBeenCalledTimes(0); + }); + + it('can delete login methods if there is more than one.', async(): Promise => { + // Original + source.get.mockResolvedValueOnce({ id: 'dum', account: 'id' }); + // Account + source.get.mockResolvedValueOnce({ id: 'id', linkedLoginsCount: 2 }); + await storage.defineType('dummy', { test: 'string', account: `id:${ACCOUNT_TYPE}` }, true); + await expect(storage.delete('dummy', 'dum')).resolves.toBeUndefined(); + expect(source.get).toHaveBeenCalledTimes(2); + expect(source.get).toHaveBeenNthCalledWith(1, 'dummy', 'dum'); + expect(source.get).toHaveBeenNthCalledWith(2, ACCOUNT_TYPE, 'id'); + expect(source.delete).toHaveBeenCalledTimes(1); + expect(source.delete).toHaveBeenLastCalledWith('dummy', 'dum'); + }); + + it('throws a 404 when deleting an unknown login entry.', async(): Promise => { + await storage.defineType('dummy', { test: 'string', account: `id:${ACCOUNT_TYPE}` }, true); + source.get.mockResolvedValueOnce(undefined); + await expect(storage.delete('dummy', 'dum')).rejects.toThrow(NotFoundHttpError); + expect(source.get).toHaveBeenCalledTimes(1); + expect(source.get).toHaveBeenLastCalledWith('dummy', 'dum'); + expect(source.delete).toHaveBeenCalledTimes(0); + }); + + it('can delete non-login entries.', async(): Promise => { + await storage.defineType('dummy', { test: 'string', account: `id:${ACCOUNT_TYPE}` }, false); + await expect(storage.delete('dummy', 'dum')).resolves.toBeUndefined(); + expect(source.get).toHaveBeenCalledTimes(0); + expect(source.delete).toHaveBeenCalledTimes(1); + expect(source.delete).toHaveBeenLastCalledWith('dummy', 'dum'); + }); + + it('calls the source when finding all entries.', async(): Promise => { + source.entries.mockReturnValueOnce((async function* (): AsyncIterableIterator { + yield { id: 'dum', account: 'id' }; + yield { id: 'id', linkedLoginsCount: 2 }; + })()); + + const result = []; + for await (const entry of storage.entries('type')) { + result.push(entry); + } + + expect(result).toEqual([ + { id: 'dum', account: 'id' }, + { id: 'id' }, + ]); + expect(source.entries).toHaveBeenCalledTimes(1); + expect(source.entries).toHaveBeenLastCalledWith('type'); + }); +}); diff --git a/test/unit/identity/interaction/client-credentials/ClientCredentialsAdapterFactory.test.ts b/test/unit/identity/interaction/client-credentials/ClientCredentialsAdapterFactory.test.ts index 1c32ba5b5..c83986006 100644 --- a/test/unit/identity/interaction/client-credentials/ClientCredentialsAdapterFactory.test.ts +++ b/test/unit/identity/interaction/client-credentials/ClientCredentialsAdapterFactory.test.ts @@ -1,17 +1,25 @@ -import type { Adapter } from 'oidc-provider'; -import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; +import { Adapter } from 'oidc-provider'; import { ClientCredentialsAdapter, ClientCredentialsAdapterFactory, } from '../../../../../src/identity/interaction/client-credentials/ClientCredentialsAdapterFactory'; import type { + ClientCredentials, +} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore'; +import { ClientCredentialsStore, } from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore'; +import { WebIdStore } from '../../../../../src/identity/interaction/webid/util/WebIdStore'; import type { AdapterFactory } from '../../../../../src/identity/storage/AdapterFactory'; -import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; describe('A ClientCredentialsAdapterFactory', (): void => { + const webId = 'http://example.com/card#me'; + const id = '123456'; + const accountId = 'accountId;'; + const label = 'token_123'; + const secret = 'secret!'; + const token: ClientCredentials = { id, label, secret, accountId, webId }; + let webIdStore: jest.Mocked; let credentialsStore: jest.Mocked; - let accountStore: jest.Mocked; let sourceAdapter: jest.Mocked; let sourceFactory: jest.Mocked; let adapter: ClientCredentialsAdapter; @@ -20,21 +28,23 @@ describe('A ClientCredentialsAdapterFactory', (): void => { beforeEach(async(): Promise => { sourceAdapter = { find: jest.fn(), - } as any; + } satisfies Partial as any; sourceFactory = { createStorageAdapter: jest.fn().mockReturnValue(sourceAdapter), }; - accountStore = mockAccountStore(); + webIdStore = { + isLinked: jest.fn().mockResolvedValue(true), + } satisfies Partial as any; credentialsStore = { - get: jest.fn(), + findByLabel: jest.fn().mockResolvedValue(token), delete: jest.fn(), - } as any; + } satisfies Partial as any; - adapter = new ClientCredentialsAdapter('Client', sourceAdapter, accountStore, credentialsStore); - factory = new ClientCredentialsAdapterFactory(sourceFactory, accountStore, credentialsStore); + adapter = new ClientCredentialsAdapter('Client', sourceAdapter, webIdStore, credentialsStore); + factory = new ClientCredentialsAdapterFactory(sourceFactory, webIdStore, credentialsStore); }); it('calls the source factory when creating a new Adapter.', async(): Promise => { @@ -45,69 +55,45 @@ describe('A ClientCredentialsAdapterFactory', (): void => { it('returns the result from the source.', async(): Promise => { sourceAdapter.find.mockResolvedValue({ payload: 'payload' }); - await expect(adapter.find('id')).resolves.toEqual({ payload: 'payload' }); + await expect(adapter.find(label)).resolves.toEqual({ payload: 'payload' }); expect(sourceAdapter.find).toHaveBeenCalledTimes(1); - expect(sourceAdapter.find).toHaveBeenLastCalledWith('id'); - expect(credentialsStore.get).toHaveBeenCalledTimes(0); - expect(accountStore.get).toHaveBeenCalledTimes(0); + expect(sourceAdapter.find).toHaveBeenLastCalledWith(label); + expect(credentialsStore.findByLabel).toHaveBeenCalledTimes(0); }); - it('tries to find a matching client credentials token if no result was found.', async(): Promise => { - await expect(adapter.find('id')).resolves.toBeUndefined(); + it('returns no result if there is no token for the label.', async(): Promise => { + credentialsStore.findByLabel.mockResolvedValueOnce(undefined); + await expect(adapter.find(label)).resolves.toBeUndefined(); expect(sourceAdapter.find).toHaveBeenCalledTimes(1); - expect(sourceAdapter.find).toHaveBeenLastCalledWith('id'); - expect(credentialsStore.get).toHaveBeenCalledTimes(1); - expect(credentialsStore.get).toHaveBeenLastCalledWith('id'); - expect(accountStore.get).toHaveBeenCalledTimes(0); - }); - - it('returns no result if there is no matching account.', async(): Promise => { - accountStore.get.mockResolvedValueOnce(undefined); - credentialsStore.get.mockResolvedValue({ secret: 'super_secret', webId: 'http://example.com/foo#me', accountId: 'accountId' }); - await expect(adapter.find('id')).resolves.toBeUndefined(); - expect(sourceAdapter.find).toHaveBeenCalledTimes(1); - expect(sourceAdapter.find).toHaveBeenLastCalledWith('id'); - expect(credentialsStore.get).toHaveBeenCalledTimes(1); - expect(credentialsStore.get).toHaveBeenLastCalledWith('id'); - expect(accountStore.get).toHaveBeenCalledTimes(1); - expect(accountStore.get).toHaveBeenLastCalledWith('accountId'); + expect(sourceAdapter.find).toHaveBeenLastCalledWith(label); + expect(credentialsStore.findByLabel).toHaveBeenCalledTimes(1); + expect(credentialsStore.findByLabel).toHaveBeenLastCalledWith(label); }); it('returns no result if the WebID is not linked to the account and deletes the token.', async(): Promise => { - const account = createAccount(); - accountStore.get.mockResolvedValueOnce(account); - credentialsStore.get.mockResolvedValue({ secret: 'super_secret', webId: 'http://example.com/foo#me', accountId: 'accountId' }); - await expect(adapter.find('id')).resolves.toBeUndefined(); + webIdStore.isLinked.mockResolvedValueOnce(false); + await expect(adapter.find(label)).resolves.toBeUndefined(); expect(sourceAdapter.find).toHaveBeenCalledTimes(1); - expect(sourceAdapter.find).toHaveBeenLastCalledWith('id'); - expect(credentialsStore.get).toHaveBeenCalledTimes(1); - expect(credentialsStore.get).toHaveBeenLastCalledWith('id'); - expect(accountStore.get).toHaveBeenCalledTimes(1); - expect(accountStore.get).toHaveBeenLastCalledWith('accountId'); + expect(sourceAdapter.find).toHaveBeenLastCalledWith(label); + expect(credentialsStore.findByLabel).toHaveBeenCalledTimes(1); + expect(credentialsStore.findByLabel).toHaveBeenLastCalledWith(label); expect(credentialsStore.delete).toHaveBeenCalledTimes(1); - expect(credentialsStore.delete).toHaveBeenLastCalledWith('id', account); + expect(credentialsStore.delete).toHaveBeenLastCalledWith(id); }); it('returns valid client_credentials Client metadata if a matching token was found.', async(): Promise => { - const webId = 'http://example.com/foo#me'; - const account = createAccount(); - account.webIds[webId] = 'resource'; - accountStore.get.mockResolvedValueOnce(account); - credentialsStore.get.mockResolvedValue({ secret: 'super_secret', webId, accountId: 'accountId' }); /* eslint-disable @typescript-eslint/naming-convention */ - await expect(adapter.find('id')).resolves.toEqual({ - client_id: 'id', - client_secret: 'super_secret', + await expect(adapter.find(label)).resolves.toEqual({ + client_id: label, + client_secret: secret, grant_types: [ 'client_credentials' ], redirect_uris: [], response_types: [], }); /* eslint-enable @typescript-eslint/naming-convention */ expect(sourceAdapter.find).toHaveBeenCalledTimes(1); - expect(sourceAdapter.find).toHaveBeenLastCalledWith('id'); - expect(credentialsStore.get).toHaveBeenCalledTimes(1); - expect(credentialsStore.get).toHaveBeenLastCalledWith('id'); - expect(accountStore.get).toHaveBeenCalledTimes(1); - expect(accountStore.get).toHaveBeenLastCalledWith('accountId'); + expect(sourceAdapter.find).toHaveBeenLastCalledWith(label); + expect(credentialsStore.findByLabel).toHaveBeenCalledTimes(1); + expect(credentialsStore.findByLabel).toHaveBeenLastCalledWith(label); }); }); diff --git a/test/unit/identity/interaction/client-credentials/ClientCredentialsDetailsHandler.test.ts b/test/unit/identity/interaction/client-credentials/ClientCredentialsDetailsHandler.test.ts index 5d4135ccd..5c2a4f598 100644 --- a/test/unit/identity/interaction/client-credentials/ClientCredentialsDetailsHandler.test.ts +++ b/test/unit/identity/interaction/client-credentials/ClientCredentialsDetailsHandler.test.ts @@ -1,59 +1,53 @@ -import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; -import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; import { ClientCredentialsDetailsHandler, } from '../../../../../src/identity/interaction/client-credentials/ClientCredentialsDetailsHandler'; import type { + ClientCredentialsIdRoute, +} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsIdRoute'; +import { ClientCredentialsStore, } from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore'; -import { InternalServerError } from '../../../../../src/util/errors/InternalServerError'; import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError'; -import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; describe('A ClientCredentialsDetailsHandler', (): void => { const webId = 'http://example.com/card#me'; - const id = 'token_id'; + const id = '123456'; + const label = 'label_789'; + const accountId = 'accountId'; const target = { path: 'http://example.com/.account/my_token' }; - let account: Account; - let accountStore: jest.Mocked; - let clientCredentialsStore: jest.Mocked; + let route: jest.Mocked; + let store: jest.Mocked; let handler: ClientCredentialsDetailsHandler; beforeEach(async(): Promise => { - account = createAccount(); - account.clientCredentials[id] = target.path; + route = { + getPath: jest.fn().mockReturnValue('http://example.com/foo'), + matchPath: jest.fn().mockReturnValue({ accountId, clientCredentialsId: id }), + }; - accountStore = mockAccountStore(account); + store = { + get: jest.fn().mockResolvedValue({ webId, accountId, label }), + } satisfies Partial as any; - clientCredentialsStore = { - get: jest.fn().mockResolvedValue({ webId, accountId: account.id, secret: 'ssh!' }), - } as any; - - handler = new ClientCredentialsDetailsHandler(accountStore, clientCredentialsStore); + handler = new ClientCredentialsDetailsHandler(store, route); }); it('returns the necessary information.', async(): Promise => { - await expect(handler.handle({ target, accountId: account.id } as any)).resolves.toEqual({ json: { id, webId }}); - expect(accountStore.get).toHaveBeenCalledTimes(1); - expect(accountStore.get).toHaveBeenLastCalledWith(account.id); - expect(clientCredentialsStore.get).toHaveBeenCalledTimes(1); - expect(clientCredentialsStore.get).toHaveBeenLastCalledWith(id); + await expect(handler.handle({ target, accountId } as any)).resolves.toEqual({ json: { id: label, webId }}); + expect(store.get).toHaveBeenCalledTimes(1); + expect(store.get).toHaveBeenLastCalledWith(id); }); it('throws a 404 if there is no such token.', async(): Promise => { - delete account.clientCredentials[id]; - await expect(handler.handle({ target, accountId: account.id } as any)).rejects.toThrow(NotFoundHttpError); - expect(accountStore.get).toHaveBeenCalledTimes(1); - expect(accountStore.get).toHaveBeenLastCalledWith(account.id); - expect(clientCredentialsStore.get).toHaveBeenCalledTimes(0); + store.get.mockResolvedValueOnce(undefined); + await expect(handler.handle({ target, accountId } as any)).rejects.toThrow(NotFoundHttpError); + expect(store.get).toHaveBeenCalledTimes(1); + expect(store.get).toHaveBeenLastCalledWith(id); }); - it('throws an error if there is a data mismatch.', async(): Promise => { - clientCredentialsStore.get.mockResolvedValueOnce(undefined); - await expect(handler.handle({ target, accountId: account.id } as any)).rejects.toThrow(InternalServerError); - expect(accountStore.get).toHaveBeenCalledTimes(1); - expect(accountStore.get).toHaveBeenLastCalledWith(account.id); - expect(clientCredentialsStore.get).toHaveBeenCalledTimes(1); - expect(clientCredentialsStore.get).toHaveBeenLastCalledWith(id); + it('throws a 404 if the account is not the owner.', async(): Promise => { + await expect(handler.handle({ target, accountId: 'otherId' } as any)).rejects.toThrow(NotFoundHttpError); + expect(store.get).toHaveBeenCalledTimes(1); + expect(store.get).toHaveBeenLastCalledWith(id); }); }); diff --git a/test/unit/identity/interaction/client-credentials/CreateClientCredentialsHandler.test.ts b/test/unit/identity/interaction/client-credentials/CreateClientCredentialsHandler.test.ts index 237512e54..6a9faf88d 100644 --- a/test/unit/identity/interaction/client-credentials/CreateClientCredentialsHandler.test.ts +++ b/test/unit/identity/interaction/client-credentials/CreateClientCredentialsHandler.test.ts @@ -1,64 +1,99 @@ -import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; -import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; import { CreateClientCredentialsHandler, } from '../../../../../src/identity/interaction/client-credentials/CreateClientCredentialsHandler'; import type { + ClientCredentialsIdRoute, +} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsIdRoute'; +import type { + ClientCredentials, +} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore'; +import { ClientCredentialsStore, } from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore'; -import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; +import { WebIdStore } from '../../../../../src/identity/interaction/webid/util/WebIdStore'; +import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError'; -jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' })); +const uuid = '4c9b88c1-7502-4107-bb79-2a3a590c7aa3'; +jest.mock('uuid', (): any => ({ v4: (): string => uuid })); describe('A CreateClientCredentialsHandler', (): void => { - let account: Account; - const json = { - webId: 'http://example.com/foo#me', - name: 'token', - }; - let accountStore: jest.Mocked; + const webId = 'http://example.com/card#me'; + const id = 'id'; + const accountId = 'accountId;'; + const label = 'token_123'; + const secret = 'secret!'; + const token: ClientCredentials = { id, label, secret, accountId, webId }; + const resource = 'http://example.com/token'; + const json = { webId, name: 'token' }; + let route: jest.Mocked; + let webIdStore: jest.Mocked; let clientCredentialsStore: jest.Mocked; let handler: CreateClientCredentialsHandler; beforeEach(async(): Promise => { - account = createAccount(); + route = { + getPath: jest.fn().mockReturnValue(resource), + matchPath: jest.fn().mockReturnValue({ accountId, clientCredentialsId: id }), + }; - accountStore = mockAccountStore(account); + webIdStore = { + isLinked: jest.fn().mockResolvedValue(true), + } satisfies Partial as any; clientCredentialsStore = { - add: jest.fn().mockReturnValue({ secret: 'secret', resource: 'resource' }), - } as any; + create: jest.fn().mockResolvedValue({ id, secret }), + findByAccount: jest.fn().mockResolvedValue([ token ]), + } satisfies Partial as any; - handler = new CreateClientCredentialsHandler(accountStore, clientCredentialsStore); + handler = new CreateClientCredentialsHandler(webIdStore, clientCredentialsStore, route); }); - it('requires specific input fields.', async(): Promise => { - await expect(handler.getView()).resolves.toEqual({ + it('shows the required fields and known tokens.', async(): Promise => { + await expect(handler.getView({ accountId } as any)).resolves.toEqual({ json: { + clientCredentials: { + [label]: resource, + }, fields: { - name: { - required: false, - type: 'string', - }, - webId: { - required: true, - type: 'string', - }, + name: { required: false, type: 'string' }, + webId: { required: true, type: 'string' }, }, }, }); + expect(clientCredentialsStore.findByAccount).toHaveBeenCalledTimes(1); + expect(clientCredentialsStore.findByAccount).toHaveBeenLastCalledWith(accountId); }); it('creates a new token based on the provided settings.', async(): Promise => { - await expect(handler.handle({ accountId: account.id, json } as any)).resolves.toEqual({ - json: { id: 'token_4c9b88c1-7502-4107-bb79-2a3a590c7aa3', secret: 'secret', resource: 'resource' }, + await expect(handler.handle({ accountId, json } as any)).resolves.toEqual({ + json: { id: 'token_4c9b88c1-7502-4107-bb79-2a3a590c7aa3', secret, resource }, }); + expect(webIdStore.isLinked).toHaveBeenCalledTimes(1); + expect(webIdStore.isLinked).toHaveBeenLastCalledWith(webId, accountId); + expect(clientCredentialsStore.create).toHaveBeenCalledTimes(1); + expect(clientCredentialsStore.create).toHaveBeenLastCalledWith(`${json.name}_${uuid}`, webId, accountId); + expect(route.getPath).toHaveBeenCalledTimes(1); + expect(route.getPath).toHaveBeenLastCalledWith({ accountId, clientCredentialsId: id }); }); it('allows token names to be empty.', async(): Promise => { - await expect(handler.handle({ accountId: account.id, json: { webId: 'http://example.com/foo#me' }} as any)) + await expect(handler.handle({ accountId, json: { webId }} as any)) .resolves.toEqual({ - json: { id: '_4c9b88c1-7502-4107-bb79-2a3a590c7aa3', secret: 'secret', resource: 'resource' }, + json: { id: '_4c9b88c1-7502-4107-bb79-2a3a590c7aa3', secret, resource }, }); + expect(webIdStore.isLinked).toHaveBeenCalledTimes(1); + expect(webIdStore.isLinked).toHaveBeenLastCalledWith(webId, accountId); + expect(clientCredentialsStore.create).toHaveBeenCalledTimes(1); + expect(clientCredentialsStore.create).toHaveBeenLastCalledWith(`_${uuid}`, webId, accountId); + expect(route.getPath).toHaveBeenCalledTimes(1); + expect(route.getPath).toHaveBeenLastCalledWith({ accountId, clientCredentialsId: id }); + }); + + it('errors if the account is not the owner of the WebID.', async(): Promise => { + webIdStore.isLinked.mockResolvedValueOnce(false); + await expect(handler.handle({ accountId, json } as any)).rejects.toThrow(BadRequestHttpError); + expect(webIdStore.isLinked).toHaveBeenCalledTimes(1); + expect(webIdStore.isLinked).toHaveBeenLastCalledWith(webId, accountId); + expect(clientCredentialsStore.create).toHaveBeenCalledTimes(0); }); }); diff --git a/test/unit/identity/interaction/client-credentials/DeleteClientCredentialsHandler.test.ts b/test/unit/identity/interaction/client-credentials/DeleteClientCredentialsHandler.test.ts index 45d3a0a87..e2cdce88a 100644 --- a/test/unit/identity/interaction/client-credentials/DeleteClientCredentialsHandler.test.ts +++ b/test/unit/identity/interaction/client-credentials/DeleteClientCredentialsHandler.test.ts @@ -1,48 +1,48 @@ -import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; -import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; import { DeleteClientCredentialsHandler, } from '../../../../../src/identity/interaction/client-credentials/DeleteClientCredentialsHandler'; import type { + ClientCredentialsIdRoute, +} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsIdRoute'; +import { ClientCredentialsStore, } from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore'; import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError'; -import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; describe('A DeleteClientCredentialsHandler', (): void => { - let account: Account; const id = 'token_id'; + const accountId = 'accountId'; const target = { path: 'http://example.com/.account/my_token' }; - let accountStore: jest.Mocked; - let clientCredentialsStore: jest.Mocked; + let route: jest.Mocked; + let store: jest.Mocked; let handler: DeleteClientCredentialsHandler; beforeEach(async(): Promise => { - account = createAccount(); - account.clientCredentials[id] = target.path; + route = { + getPath: jest.fn().mockReturnValue(target.path), + matchPath: jest.fn().mockReturnValue({ accountId, clientCredentialsId: id }), + }; - accountStore = mockAccountStore(account); - - clientCredentialsStore = { + store = { + get: jest.fn().mockResolvedValue({ accountId }), delete: jest.fn(), - } as any; + } satisfies Partial as any; - handler = new DeleteClientCredentialsHandler(accountStore, clientCredentialsStore); + handler = new DeleteClientCredentialsHandler(store, route); }); it('deletes the token.', async(): Promise => { - await expect(handler.handle({ target, accountId: account.id } as any)).resolves.toEqual({ json: {}}); - expect(accountStore.get).toHaveBeenCalledTimes(1); - expect(accountStore.get).toHaveBeenLastCalledWith(account.id); - expect(clientCredentialsStore.delete).toHaveBeenCalledTimes(1); - expect(clientCredentialsStore.delete).toHaveBeenLastCalledWith(id, account); + await expect(handler.handle({ target, accountId } as any)).resolves.toEqual({ json: {}}); + expect(store.get).toHaveBeenCalledTimes(1); + expect(store.get).toHaveBeenLastCalledWith(id); + expect(store.delete).toHaveBeenCalledTimes(1); + expect(store.delete).toHaveBeenLastCalledWith(id); }); - it('throws a 404 if there is no such token.', async(): Promise => { - delete account.clientCredentials[id]; - await expect(handler.handle({ target, accountId: account.id } as any)).rejects.toThrow(NotFoundHttpError); - expect(accountStore.get).toHaveBeenCalledTimes(1); - expect(accountStore.get).toHaveBeenLastCalledWith(account.id); - expect(clientCredentialsStore.delete).toHaveBeenCalledTimes(0); + it('throws a 404 if the authenticated accountId is not the owner.', async(): Promise => { + await expect(handler.handle({ target, accountId: 'otherId' } as any)).rejects.toThrow(NotFoundHttpError); + expect(store.get).toHaveBeenCalledTimes(1); + expect(store.get).toHaveBeenLastCalledWith(id); + expect(store.delete).toHaveBeenCalledTimes(0); }); }); diff --git a/test/unit/identity/interaction/client-credentials/util/BaseClientCredentialsStore.test.ts b/test/unit/identity/interaction/client-credentials/util/BaseClientCredentialsStore.test.ts index 48da777a0..c64f8b028 100644 --- a/test/unit/identity/interaction/client-credentials/util/BaseClientCredentialsStore.test.ts +++ b/test/unit/identity/interaction/client-credentials/util/BaseClientCredentialsStore.test.ts @@ -1,91 +1,98 @@ -import type { Account } from '../../../../../../src/identity/interaction/account/util/Account'; -import type { AccountStore } from '../../../../../../src/identity/interaction/account/util/AccountStore'; +import { + ACCOUNT_TYPE, + AccountLoginStorage, +} from '../../../../../../src/identity/interaction/account/util/LoginStorage'; import { BaseClientCredentialsStore, } from '../../../../../../src/identity/interaction/client-credentials/util/BaseClientCredentialsStore'; -import type { - ClientCredentialsIdRoute, -} from '../../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsIdRoute'; import type { ClientCredentials, } from '../../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore'; -import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage'; -import { BadRequestHttpError } from '../../../../../../src/util/errors/BadRequestHttpError'; -import { createAccount, mockAccountStore } from '../../../../../util/AccountUtil'; +import { InternalServerError } from '../../../../../../src/util/errors/InternalServerError'; +const STORAGE_TYPE = 'clientCredentials'; const secret = 'verylongstringof64bytes'; jest.mock('crypto', (): any => ({ randomBytes: (): string => secret })); describe('A BaseClientCredentialsStore', (): void => { const webId = 'http://example.com/card#me'; - let account: Account; - const route: ClientCredentialsIdRoute = { - getPath: (): string => 'http://example.com/.account/resource', - matchPath: (): any => ({}), - }; - let accountStore: jest.Mocked; - let storage: jest.Mocked>; + const id = 'id'; + const accountId = 'accountId;'; + const label = 'token'; + const token: ClientCredentials = { id, label, secret, accountId, webId }; + let storage: jest.Mocked>; let store: BaseClientCredentialsStore; beforeEach(async(): Promise => { - account = createAccount(); - account.webIds[webId] = 'resource'; - - // Different account object so `safeUpdate` can be tested correctly - const oldAccount = createAccount(); - oldAccount.webIds[webId] = 'resource'; - accountStore = mockAccountStore(oldAccount); - storage = { - get: jest.fn().mockResolvedValue({ accountId: account.id, webId, secret: 'secret' }), - set: jest.fn(), + defineType: jest.fn().mockResolvedValue({}), + createIndex: jest.fn().mockResolvedValue({}), + create: jest.fn().mockResolvedValue(token), + get: jest.fn().mockResolvedValue(token), + find: jest.fn().mockResolvedValue([ token ]), delete: jest.fn(), - } as any; + } satisfies Partial> as any; - store = new BaseClientCredentialsStore(route, accountStore, storage); + store = new BaseClientCredentialsStore(storage); + }); + + it('defines the type and indexes in the storage.', async(): Promise => { + await expect(store.handle()).resolves.toBeUndefined(); + expect(storage.defineType).toHaveBeenCalledTimes(1); + expect(storage.defineType).toHaveBeenLastCalledWith(STORAGE_TYPE, { + label: 'string', + accountId: `id:${ACCOUNT_TYPE}`, + secret: 'string', + webId: 'string', + }, false); + expect(storage.createIndex).toHaveBeenCalledTimes(2); + expect(storage.createIndex).toHaveBeenCalledWith(STORAGE_TYPE, 'accountId'); + expect(storage.createIndex).toHaveBeenCalledWith(STORAGE_TYPE, 'label'); + }); + + it('can only initialize once.', async(): Promise => { + await expect(store.handle()).resolves.toBeUndefined(); + await expect(store.handle()).resolves.toBeUndefined(); + expect(storage.defineType).toHaveBeenCalledTimes(1); + }); + + it('throws an error if defining the type goes wrong.', async(): Promise => { + storage.defineType.mockRejectedValueOnce(new Error('bad data')); + await expect(store.handle()).rejects.toThrow(InternalServerError); }); it('returns the token it finds.', async(): Promise => { - await expect(store.get('credentialsId')).resolves.toEqual({ accountId: account.id, webId, secret: 'secret' }); + await expect(store.get(id)).resolves.toEqual(token); expect(storage.get).toHaveBeenCalledTimes(1); - expect(storage.get).toHaveBeenLastCalledWith('credentialsId'); + expect(storage.get).toHaveBeenLastCalledWith(STORAGE_TYPE, id); }); - it('creates a new token and adds it to the account.', async(): Promise => { - await expect(store.add('credentialsId', webId, account)).resolves - .toEqual({ secret, resource: 'http://example.com/.account/resource' }); - expect(account.clientCredentials.credentialsId).toBe('http://example.com/.account/resource'); - expect(storage.set).toHaveBeenCalledTimes(1); - expect(storage.set).toHaveBeenLastCalledWith('credentialsId', { secret, accountId: account.id, webId }); - expect(accountStore.update).toHaveBeenCalledTimes(1); - expect(accountStore.update).toHaveBeenLastCalledWith(account); + it('can find the token using its label.', async(): Promise => { + await expect(store.findByLabel(label)).resolves.toEqual(token); + expect(storage.find).toHaveBeenCalledTimes(1); + expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { label }); + + storage.find.mockResolvedValueOnce([]); + await expect(store.findByLabel(label)).resolves.toBeUndefined(); + expect(storage.find).toHaveBeenCalledTimes(2); + expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { label }); }); - it('errors if the WebID is not registered to the account.', async(): Promise => { - delete account.webIds[webId]; - await expect(store.add('credentialsId', webId, account)).rejects.toThrow(BadRequestHttpError); - expect(storage.set).toHaveBeenCalledTimes(0); - expect(accountStore.update).toHaveBeenCalledTimes(0); - expect(account.clientCredentials).toEqual({}); + it('can find the token using its accountId.', async(): Promise => { + await expect(store.findByAccount(accountId)).resolves.toEqual([ token ]); + expect(storage.find).toHaveBeenCalledTimes(1); + expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { accountId }); }); - it('does not update the account if something goes wrong.', async(): Promise => { - storage.set.mockRejectedValueOnce(new Error('bad data')); - await expect(store.add('credentialsId', webId, account)).rejects.toThrow('bad data'); - expect(storage.set).toHaveBeenCalledTimes(1); - expect(storage.set).toHaveBeenLastCalledWith('credentialsId', { secret, accountId: account.id, webId }); - expect(accountStore.update).toHaveBeenCalledTimes(2); - expect(accountStore.update).toHaveBeenLastCalledWith(account); - expect(account.clientCredentials).toEqual({}); + it('can create new tokens.', async(): Promise => { + await expect(store.create(label, webId, accountId)).resolves.toEqual(token); + expect(storage.create).toHaveBeenCalledTimes(1); + expect(storage.create).toHaveBeenLastCalledWith(STORAGE_TYPE, { label, webId, accountId, secret }); }); it('can delete tokens.', async(): Promise => { - account.clientCredentials.credentialsId = 'resource'; - await expect(store.delete('credentialsId', account)).resolves.toBeUndefined(); + await expect(store.delete(id)).resolves.toBeUndefined(); expect(storage.delete).toHaveBeenCalledTimes(1); - expect(storage.delete).toHaveBeenLastCalledWith('credentialsId'); - expect(accountStore.update).toHaveBeenCalledTimes(1); - expect(accountStore.update).toHaveBeenLastCalledWith(account); - expect(account.clientCredentials).toEqual({}); + expect(storage.delete).toHaveBeenLastCalledWith(STORAGE_TYPE, id); }); }); diff --git a/test/unit/identity/interaction/login/ResolveLoginHandler.test.ts b/test/unit/identity/interaction/login/ResolveLoginHandler.test.ts index 9d97fef57..93621873c 100644 --- a/test/unit/identity/interaction/login/ResolveLoginHandler.test.ts +++ b/test/unit/identity/interaction/login/ResolveLoginHandler.test.ts @@ -1,24 +1,20 @@ import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata'; -import type { AccountIdRoute } from '../../../../../src/identity/interaction/account/AccountIdRoute'; -import { ACCOUNT_SETTINGS_REMEMBER_LOGIN } from '../../../../../src/identity/interaction/account/util/Account'; -import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; -import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; -import type { CookieStore } from '../../../../../src/identity/interaction/account/util/CookieStore'; +import { ACCOUNT_SETTINGS_REMEMBER_LOGIN, + AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; +import { CookieStore } from '../../../../../src/identity/interaction/account/util/CookieStore'; import type { JsonRepresentation } from '../../../../../src/identity/interaction/InteractionUtil'; import type { JsonInteractionHandlerInput } from '../../../../../src/identity/interaction/JsonInteractionHandler'; import type { LoginOutputType } from '../../../../../src/identity/interaction/login/ResolveLoginHandler'; import { ResolveLoginHandler, } from '../../../../../src/identity/interaction/login/ResolveLoginHandler'; -import { InternalServerError } from '../../../../../src/util/errors/InternalServerError'; import { CONTENT_TYPE, CONTENT_TYPE_TERM, SOLID_HTTP } from '../../../../../src/util/Vocabularies'; -import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; const accountId = 'accountId'; let output: JsonRepresentation; class DummyLoginHandler extends ResolveLoginHandler { - public constructor(accountStore: AccountStore, cookieStore: CookieStore, accountRoute: AccountIdRoute) { - super(accountStore, cookieStore, accountRoute); + public constructor(accountStore: AccountStore, cookieStore: CookieStore) { + super(accountStore, cookieStore); } public async login(): Promise> { @@ -30,10 +26,8 @@ describe('A ResolveLoginHandler', (): void => { const cookie = 'cookie'; let metadata: RepresentationMetadata; let input: JsonInteractionHandlerInput; - let account: Account; let accountStore: jest.Mocked; let cookieStore: jest.Mocked; - let accountRoute: jest.Mocked; let handler: DummyLoginHandler; beforeEach(async(): Promise => { @@ -50,27 +44,22 @@ describe('A ResolveLoginHandler', (): void => { metadata, }; - account = createAccount(); - accountStore = mockAccountStore(account); + accountStore = { + updateSetting: jest.fn(), + } satisfies Partial as any; cookieStore = { generate: jest.fn().mockResolvedValue(cookie), delete: jest.fn(), - } as any; + } satisfies Partial as any; - accountRoute = { - getPath: jest.fn().mockReturnValue('http://example.com/foo'), - matchPath: jest.fn().mockReturnValue(true), - }; - - handler = new DummyLoginHandler(accountStore, cookieStore, accountRoute); + handler = new DummyLoginHandler(accountStore, cookieStore); }); it('removes the ID from the output and adds a cookie.', async(): Promise => { await expect(handler.handle(input)).resolves.toEqual({ json: { data: 'data', cookie, - resource: 'http://example.com/foo', }, metadata }); expect(metadata.get(SOLID_HTTP.terms.accountCookie)?.value).toBe(cookie); @@ -78,7 +67,7 @@ describe('A ResolveLoginHandler', (): void => { expect(cookieStore.generate).toHaveBeenCalledTimes(1); expect(cookieStore.generate).toHaveBeenLastCalledWith(accountId); expect(cookieStore.delete).toHaveBeenCalledTimes(0); - expect(accountStore.get).toHaveBeenCalledTimes(0); + expect(accountStore.updateSetting).toHaveBeenCalledTimes(0); }); it('generates a metadata object if the login handler did not provide one.', async(): Promise => { @@ -87,12 +76,11 @@ describe('A ResolveLoginHandler', (): void => { expect(result).toEqual({ json: { data: 'data', cookie, - resource: 'http://example.com/foo', }, metadata: expect.any(RepresentationMetadata) }); expect(result.metadata).not.toBe(metadata); expect(result.metadata?.get(CONTENT_TYPE_TERM)).toBeUndefined(); - expect(accountStore.get).toHaveBeenCalledTimes(0); + expect(accountStore.updateSetting).toHaveBeenCalledTimes(0); }); it('adds a location field if there is an OIDC interaction.', async(): Promise => { @@ -104,7 +92,6 @@ describe('A ResolveLoginHandler', (): void => { await expect(handler.handle(input)).resolves.toEqual({ json: { data: 'data', cookie, - resource: 'http://example.com/foo', location: 'returnTo', }, metadata }); @@ -113,7 +100,7 @@ describe('A ResolveLoginHandler', (): void => { expect(input.oidcInteraction!.result).toEqual({ login: { accountId: 'id' }, }); - expect(accountStore.get).toHaveBeenCalledTimes(0); + expect(accountStore.updateSetting).toHaveBeenCalledTimes(0); }); it('updates the account remember settings if necessary.', async(): Promise => { @@ -124,30 +111,13 @@ describe('A ResolveLoginHandler', (): void => { await expect(handler.handle(input)).resolves.toEqual({ json: { data: 'data', cookie, - resource: 'http://example.com/foo', }, metadata }); expect(cookieStore.generate).toHaveBeenCalledTimes(1); expect(cookieStore.generate).toHaveBeenLastCalledWith(accountId); - expect(accountStore.get).toHaveBeenCalledTimes(1); - expect(accountStore.get).toHaveBeenLastCalledWith(accountId); - expect(accountStore.update).toHaveBeenCalledTimes(1); - expect(accountStore.update).toHaveBeenLastCalledWith(account); - expect(account.settings[ACCOUNT_SETTINGS_REMEMBER_LOGIN]).toBe(true); - }); - - it('errors if the account can not be found.', async(): Promise => { - output = { - json: { ...output.json, remember: true }, - metadata, - }; - accountStore.get.mockResolvedValue(undefined); - await expect(handler.handle(input)).rejects.toThrow(InternalServerError); - - expect(accountStore.get).toHaveBeenCalledTimes(1); - expect(accountStore.get).toHaveBeenLastCalledWith(accountId); - expect(accountStore.update).toHaveBeenCalledTimes(0); + expect(accountStore.updateSetting).toHaveBeenCalledTimes(1); + expect(accountStore.updateSetting).toHaveBeenLastCalledWith(accountId, ACCOUNT_SETTINGS_REMEMBER_LOGIN, true); }); it('deletes the old cookie if there was one in the input.', async(): Promise => { @@ -155,7 +125,6 @@ describe('A ResolveLoginHandler', (): void => { await expect(handler.handle(input)).resolves.toEqual({ json: { data: 'data', cookie, - resource: 'http://example.com/foo', }, metadata }); expect(metadata.get(SOLID_HTTP.terms.accountCookie)?.value).toBe(cookie); @@ -164,6 +133,6 @@ describe('A ResolveLoginHandler', (): void => { expect(cookieStore.generate).toHaveBeenLastCalledWith(accountId); expect(cookieStore.delete).toHaveBeenCalledTimes(1); expect(cookieStore.delete).toHaveBeenLastCalledWith('old-cookie-value'); - expect(accountStore.get).toHaveBeenCalledTimes(0); + expect(accountStore.updateSetting).toHaveBeenCalledTimes(0); }); }); diff --git a/test/unit/identity/interaction/oidc/PickWebIdHandler.test.ts b/test/unit/identity/interaction/oidc/PickWebIdHandler.test.ts index d71621593..e45c63d09 100644 --- a/test/unit/identity/interaction/oidc/PickWebIdHandler.test.ts +++ b/test/unit/identity/interaction/oidc/PickWebIdHandler.test.ts @@ -1,12 +1,10 @@ import type { ProviderFactory } from '../../../../../src/identity/configuration/ProviderFactory'; -import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; -import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; import type { Interaction } from '../../../../../src/identity/interaction/InteractionHandler'; import { PickWebIdHandler } from '../../../../../src/identity/interaction/oidc/PickWebIdHandler'; +import { WebIdStore } from '../../../../../src/identity/interaction/webid/util/WebIdStore'; import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError'; import { FoundHttpError } from '../../../../../src/util/errors/FoundHttpError'; import type Provider from '../../../../../templates/types/oidc-provider'; -import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; describe('A PickWebIdHandler', (): void => { const accountId = 'accountId'; @@ -14,8 +12,7 @@ describe('A PickWebIdHandler', (): void => { const webId2 = 'http://example.com/.account/card2#me'; let json: unknown; let oidcInteraction: Interaction; - let account: Account; - let accountStore: jest.Mocked; + let store: jest.Mocked; let provider: jest.Mocked; let providerFactory: jest.Mocked; let picker: PickWebIdHandler; @@ -24,9 +21,7 @@ describe('A PickWebIdHandler', (): void => { oidcInteraction = { lastSubmission: { login: { accountId: 'id' }}, persist: jest.fn(), - session: { - cookie: 'cookie', - }, + session: { cookie: 'cookie' }, returnTo: 'returnTo', } as any; @@ -34,11 +29,10 @@ describe('A PickWebIdHandler', (): void => { webId: webId1, }; - account = createAccount(accountId); - account.webIds[webId1] = 'resource'; - account.webIds[webId2] = 'resource'; - - accountStore = mockAccountStore(account); + store = { + findLinks: jest.fn().mockResolvedValue([{ id: 'id', webId: webId1 }, { id: 'id2', webId: webId2 }]), + isLinked: jest.fn().mockResolvedValue(true), + } satisfies Partial as any; provider = { /* eslint-disable @typescript-eslint/naming-convention */ @@ -52,28 +46,21 @@ describe('A PickWebIdHandler', (): void => { getProvider: jest.fn().mockResolvedValue(provider), }; - picker = new PickWebIdHandler(accountStore, providerFactory); + picker = new PickWebIdHandler(store, providerFactory); }); it('requires a WebID as input and returns the available WebIDs.', async(): Promise => { await expect(picker.getView({ accountId } as any)).resolves.toEqual({ json: { fields: { - webId: { - required: true, - type: 'string', - }, - remember: { - required: false, - type: 'boolean', - }, + webId: { required: true, type: 'string' }, + remember: { required: false, type: 'boolean' }, }, - webIds: [ - webId1, - webId2, - ], + webIds: [ webId1, webId2 ], }, }); + expect(store.findLinks).toHaveBeenCalledTimes(1); + expect(store.findLinks).toHaveBeenLastCalledWith(accountId); }); it('allows users to pick a WebID.', async(): Promise => { @@ -81,6 +68,8 @@ describe('A PickWebIdHandler', (): void => { await expect(result).rejects.toThrow(FoundHttpError); await expect(result).rejects.toEqual(expect.objectContaining({ location: oidcInteraction.returnTo })); + expect(store.isLinked).toHaveBeenCalledTimes(1); + expect(store.isLinked).toHaveBeenLastCalledWith(webId1, accountId); expect((await (provider.Session.find as jest.Mock).mock.results[0].value).persist).toHaveBeenCalledTimes(1); expect(oidcInteraction.persist).toHaveBeenCalledTimes(1); expect(oidcInteraction.result).toEqual({ @@ -96,8 +85,11 @@ describe('A PickWebIdHandler', (): void => { }); it('errors if the WebID is not part of the account.', async(): Promise => { - json = { webId: 'http://example.com/somewhere/else#me' }; + store.isLinked.mockResolvedValueOnce(false); await expect(picker.handle({ oidcInteraction, accountId, json } as any)) .rejects.toThrow('WebID does not belong to this account.'); + expect(store.isLinked).toHaveBeenCalledTimes(1); + expect(store.isLinked).toHaveBeenLastCalledWith(webId1, accountId); + expect(oidcInteraction.persist).toHaveBeenCalledTimes(0); }); }); diff --git a/test/unit/identity/interaction/password/CreatePasswordHandler.test.ts b/test/unit/identity/interaction/password/CreatePasswordHandler.test.ts index 9f6846793..83d59f8c5 100644 --- a/test/unit/identity/interaction/password/CreatePasswordHandler.test.ts +++ b/test/unit/identity/interaction/password/CreatePasswordHandler.test.ts @@ -1,91 +1,55 @@ -import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; -import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; import { CreatePasswordHandler } from '../../../../../src/identity/interaction/password/CreatePasswordHandler'; import type { PasswordIdRoute } from '../../../../../src/identity/interaction/password/util/PasswordIdRoute'; -import { PASSWORD_METHOD } from '../../../../../src/identity/interaction/password/util/PasswordStore'; -import type { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore'; -import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; +import { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore'; describe('A CreatePasswordHandler', (): void => { + const id = 'id'; + const accountId = 'accountId'; const email = 'example@example.com'; const password = 'supersecret!'; const resource = 'http://example.com/foo'; - let account: Account; let json: unknown; - let passwordStore: jest.Mocked; - let accountStore: jest.Mocked; - let passwordRoute: PasswordIdRoute; + let store: jest.Mocked; + let route: PasswordIdRoute; let handler: CreatePasswordHandler; beforeEach(async(): Promise => { json = { email, password }; - passwordStore = { - create: jest.fn(), + store = { + create: jest.fn().mockResolvedValue(id), + findByAccount: jest.fn().mockResolvedValue([{ id: 'id', email }]), confirmVerification: jest.fn(), delete: jest.fn(), - } as any; + } satisfies Partial as any; - account = createAccount(); - accountStore = mockAccountStore(account); - - passwordRoute = { + route = { getPath: jest.fn().mockReturnValue(resource), matchPath: jest.fn().mockReturnValue(true), }; - handler = new CreatePasswordHandler(passwordStore, accountStore, passwordRoute); + handler = new CreatePasswordHandler(store, route); }); - it('requires specific input fields.', async(): Promise => { - await expect(handler.getView()).resolves.toEqual({ + it('returns the required input fields and known logins.', async(): Promise => { + await expect(handler.getView({ accountId } as any)).resolves.toEqual({ json: { + passwordLogins: { + [email]: resource, + }, fields: { - email: { - required: true, - type: 'string', - }, - password: { - required: true, - type: 'string', - }, + email: { required: true, type: 'string' }, + password: { required: true, type: 'string' }, }, }, }); }); it('returns the resource URL of the created login.', async(): Promise => { - await expect(handler.handle({ accountId: account.id, json } as any)).resolves.toEqual({ json: { resource }}); - expect(passwordStore.create).toHaveBeenCalledTimes(1); - expect(passwordStore.create).toHaveBeenLastCalledWith(email, account.id, password); - expect(passwordStore.confirmVerification).toHaveBeenCalledTimes(1); - expect(passwordStore.confirmVerification).toHaveBeenLastCalledWith(email); - expect(accountStore.update).toHaveBeenCalledTimes(1); - expect(accountStore.update).toHaveBeenLastCalledWith(account); - expect(account.logins[PASSWORD_METHOD]?.[email]).toBe(resource); - expect(passwordStore.delete).toHaveBeenCalledTimes(0); - }); - - it('throws an error if the account already has a login with this email address.', async(): Promise => { - await handler.handle({ accountId: account.id, json } as any); - jest.clearAllMocks(); - await expect(handler.handle({ accountId: account.id, json } as any)) - .rejects.toThrow('This account already has a login method for this e-mail address.'); - expect(passwordStore.create).toHaveBeenCalledTimes(0); - expect(accountStore.update).toHaveBeenCalledTimes(0); - }); - - it('reverts changes if there is an error writing the data.', async(): Promise => { - const error = new Error('bad data'); - accountStore.update.mockRejectedValueOnce(error); - await expect(handler.handle({ accountId: account.id, json } as any)).rejects.toThrow(error); - expect(passwordStore.create).toHaveBeenCalledTimes(1); - expect(passwordStore.create).toHaveBeenLastCalledWith(email, account.id, password); - expect(passwordStore.confirmVerification).toHaveBeenCalledTimes(1); - expect(passwordStore.confirmVerification).toHaveBeenLastCalledWith(email); - expect(accountStore.update).toHaveBeenCalledTimes(1); - expect(accountStore.update).toHaveBeenLastCalledWith(account); - expect(passwordStore.delete).toHaveBeenCalledTimes(1); - expect(passwordStore.delete).toHaveBeenLastCalledWith(email); + await expect(handler.handle({ accountId, json } as any)).resolves.toEqual({ json: { resource }}); + expect(store.create).toHaveBeenCalledTimes(1); + expect(store.create).toHaveBeenLastCalledWith(email, accountId, password); + expect(store.confirmVerification).toHaveBeenCalledTimes(1); + expect(store.confirmVerification).toHaveBeenLastCalledWith(id); }); }); diff --git a/test/unit/identity/interaction/password/DeletePasswordHandler.test.ts b/test/unit/identity/interaction/password/DeletePasswordHandler.test.ts index 578586511..2ceb77862 100644 --- a/test/unit/identity/interaction/password/DeletePasswordHandler.test.ts +++ b/test/unit/identity/interaction/password/DeletePasswordHandler.test.ts @@ -1,82 +1,43 @@ -import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; -import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; - import { DeletePasswordHandler } from '../../../../../src/identity/interaction/password/DeletePasswordHandler'; -import { PASSWORD_METHOD } from '../../../../../src/identity/interaction/password/util/PasswordStore'; -import type { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore'; +import type { PasswordIdRoute } from '../../../../../src/identity/interaction/password/util/PasswordIdRoute'; +import { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore'; import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError'; -import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; describe('A DeletePasswordHandler', (): void => { + const id = 'id'; const accountId = 'accountId'; const email = 'example@example.com'; const target = { path: 'http://example.com/.account/password' }; - let accountStore: jest.Mocked; - let passwordStore: jest.Mocked; + let store: jest.Mocked; + let route: jest.Mocked; let handler: DeletePasswordHandler; beforeEach(async(): Promise => { - accountStore = mockAccountStore(); - accountStore.get.mockImplementation(async(id: string): Promise => { - const account = createAccount(id); - account.logins[PASSWORD_METHOD] = { [email]: target.path }; - return account; - }); - - passwordStore = { + store = { + get: jest.fn().mockResolvedValue({ email, accountId }), delete: jest.fn(), - } as any; + } satisfies Partial as any; - handler = new DeletePasswordHandler(accountStore, passwordStore); + route = { + getPath: jest.fn().mockReturnValue(''), + matchPath: jest.fn().mockReturnValue({ accountId, passwordId: id }), + }; + + handler = new DeletePasswordHandler(store, route); }); it('deletes the token.', async(): Promise => { await expect(handler.handle({ target, accountId } as any)).resolves.toEqual({ json: {}}); - // Once to find initial account and once for backup during `safeUpdate` - expect(accountStore.get).toHaveBeenCalledTimes(2); - expect(accountStore.get).toHaveBeenNthCalledWith(1, accountId); - expect(accountStore.get).toHaveBeenNthCalledWith(2, accountId); - expect(accountStore.update).toHaveBeenCalledTimes(1); - const account: Account = await accountStore.get.mock.results[0].value; - expect(accountStore.update).toHaveBeenLastCalledWith(account); - expect(account.logins[PASSWORD_METHOD]![email]).toBeUndefined(); - expect(passwordStore.delete).toHaveBeenCalledTimes(1); - expect(passwordStore.delete).toHaveBeenLastCalledWith(email); + expect(store.get).toHaveBeenCalledTimes(1); + expect(store.get).toHaveBeenLastCalledWith(id); + expect(store.delete).toHaveBeenCalledTimes(1); + expect(store.delete).toHaveBeenLastCalledWith(id); }); - it('throws a 404 if there are no logins.', async(): Promise => { - accountStore.get.mockResolvedValueOnce(createAccount()); - await expect(handler.handle({ target, accountId } as any)).rejects.toThrow(NotFoundHttpError); - expect(accountStore.get).toHaveBeenCalledTimes(1); - expect(accountStore.get).toHaveBeenLastCalledWith(accountId); - expect(accountStore.update).toHaveBeenCalledTimes(0); - expect(passwordStore.delete).toHaveBeenCalledTimes(0); - }); - - it('throws a 404 if there is no such token.', async(): Promise => { - const account = createAccount(accountId); - account.logins[PASSWORD_METHOD] = {}; - accountStore.get.mockResolvedValueOnce(account); - await expect(handler.handle({ target, accountId } as any)).rejects.toThrow(NotFoundHttpError); - expect(accountStore.get).toHaveBeenCalledTimes(1); - expect(accountStore.get).toHaveBeenLastCalledWith(accountId); - expect(accountStore.update).toHaveBeenCalledTimes(0); - expect(passwordStore.delete).toHaveBeenCalledTimes(0); - }); - - it('reverts the changes if there was a data error.', async(): Promise => { - const error = new Error('bad data'); - passwordStore.delete.mockRejectedValueOnce(error); - await expect(handler.handle({ target, accountId } as any)).rejects.toThrow(error); - expect(accountStore.get).toHaveBeenCalledTimes(2); - expect(accountStore.get).toHaveBeenNthCalledWith(1, accountId); - expect(accountStore.get).toHaveBeenNthCalledWith(2, accountId); - expect(accountStore.update).toHaveBeenCalledTimes(2); - expect(accountStore.update).toHaveBeenNthCalledWith(1, await accountStore.get.mock.results[0].value); - expect(accountStore.update).toHaveBeenNthCalledWith(2, expect.objectContaining({ - logins: { [PASSWORD_METHOD]: { [email]: target.path }}, - })); - expect(passwordStore.delete).toHaveBeenCalledTimes(1); - expect(passwordStore.delete).toHaveBeenLastCalledWith(email); + it('throws a 404 if the authenticated accountId is not the owner.', async(): Promise => { + await expect(handler.handle({ target, accountId: 'otherId' } as any)).rejects.toThrow(NotFoundHttpError); + expect(store.get).toHaveBeenCalledTimes(1); + expect(store.get).toHaveBeenLastCalledWith(id); + expect(store.delete).toHaveBeenCalledTimes(0); }); }); diff --git a/test/unit/identity/interaction/password/ForgotPasswordHandler.test.ts b/test/unit/identity/interaction/password/ForgotPasswordHandler.test.ts index 09f38b258..dba7a4aa7 100644 --- a/test/unit/identity/interaction/password/ForgotPasswordHandler.test.ts +++ b/test/unit/identity/interaction/password/ForgotPasswordHandler.test.ts @@ -1,11 +1,12 @@ import { ForgotPasswordHandler } from '../../../../../src/identity/interaction/password/ForgotPasswordHandler'; -import type { EmailSender } from '../../../../../src/identity/interaction/password/util/EmailSender'; -import type { ForgotPasswordStore } from '../../../../../src/identity/interaction/password/util/ForgotPasswordStore'; -import type { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore'; +import { EmailSender } from '../../../../../src/identity/interaction/password/util/EmailSender'; +import { ForgotPasswordStore } from '../../../../../src/identity/interaction/password/util/ForgotPasswordStore'; +import { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore'; import type { InteractionRoute } from '../../../../../src/identity/interaction/routing/InteractionRoute'; -import type { TemplateEngine } from '../../../../../src/util/templates/TemplateEngine'; +import { TemplateEngine } from '../../../../../src/util/templates/TemplateEngine'; describe('A ForgotPasswordHandler', (): void => { + const id = 'id'; const accountId = 'accountId'; let json: unknown; const email = 'test@test.email'; @@ -22,16 +23,16 @@ describe('A ForgotPasswordHandler', (): void => { json = { email }; passwordStore = { - get: jest.fn().mockResolvedValue(accountId), - } as any; + findByEmail: jest.fn().mockResolvedValue({ id, accountId }), + } satisfies Partial as any; forgotPasswordStore = { generate: jest.fn().mockResolvedValue(recordId), - } as any; + } satisfies Partial as any; templateEngine = { handleSafe: jest.fn().mockResolvedValue(html), - } as any; + } satisfies Partial as any; resetRoute = { getPath: jest.fn().mockReturnValue('http://test.com/base/idp/resetpassword/'), @@ -40,7 +41,7 @@ describe('A ForgotPasswordHandler', (): void => { emailSender = { handleSafe: jest.fn(), - } as any; + } satisfies Partial as any; handler = new ForgotPasswordHandler({ passwordStore, @@ -65,13 +66,20 @@ describe('A ForgotPasswordHandler', (): void => { }); it('does not send a mail if a ForgotPassword record could not be generated.', async(): Promise => { - passwordStore.get.mockResolvedValueOnce(undefined); + passwordStore.findByEmail.mockResolvedValueOnce(undefined); await expect(handler.handle({ json } as any)).resolves.toEqual({ json: { email }}); + expect(passwordStore.findByEmail).toHaveBeenCalledTimes(1); + expect(passwordStore.findByEmail).toHaveBeenLastCalledWith(email); + expect(forgotPasswordStore.generate).toHaveBeenCalledTimes(0); expect(emailSender.handleSafe).toHaveBeenCalledTimes(0); }); it('sends a mail if a ForgotPassword record could be generated.', async(): Promise => { await expect(handler.handle({ json } as any)).resolves.toEqual({ json: { email }}); + expect(passwordStore.findByEmail).toHaveBeenCalledTimes(1); + expect(passwordStore.findByEmail).toHaveBeenLastCalledWith(email); + expect(forgotPasswordStore.generate).toHaveBeenCalledTimes(1); + expect(forgotPasswordStore.generate).toHaveBeenLastCalledWith(id); expect(emailSender.handleSafe).toHaveBeenCalledTimes(1); expect(emailSender.handleSafe).toHaveBeenLastCalledWith({ recipient: email, @@ -84,6 +92,10 @@ describe('A ForgotPasswordHandler', (): void => { it('catches the error if there was an issue sending the mail.', async(): Promise => { emailSender.handleSafe.mockRejectedValueOnce(new Error('bad data')); await expect(handler.handle({ json } as any)).resolves.toEqual({ json: { email }}); + expect(passwordStore.findByEmail).toHaveBeenCalledTimes(1); + expect(passwordStore.findByEmail).toHaveBeenLastCalledWith(email); + expect(forgotPasswordStore.generate).toHaveBeenCalledTimes(1); + expect(forgotPasswordStore.generate).toHaveBeenLastCalledWith(id); expect(emailSender.handleSafe).toHaveBeenCalledTimes(1); }); }); diff --git a/test/unit/identity/interaction/password/PasswordLoginHandler.test.ts b/test/unit/identity/interaction/password/PasswordLoginHandler.test.ts index 551cbf1eb..aa1f86d5d 100644 --- a/test/unit/identity/interaction/password/PasswordLoginHandler.test.ts +++ b/test/unit/identity/interaction/password/PasswordLoginHandler.test.ts @@ -1,5 +1,5 @@ import { PasswordLoginHandler } from '../../../../../src/identity/interaction/password/PasswordLoginHandler'; -import type { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore'; +import { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore'; describe('A PasswordLoginHandler', (): void => { let json: unknown; @@ -13,13 +13,12 @@ describe('A PasswordLoginHandler', (): void => { json = { email, password }; passwordStore = { - authenticate: jest.fn().mockResolvedValue(accountId), - } as any; + authenticate: jest.fn().mockResolvedValue({ accountId }), + } satisfies Partial as any; handler = new PasswordLoginHandler({ passwordStore, accountStore: {} as any, - accountRoute: {} as any, cookieStore: {} as any, }); }); diff --git a/test/unit/identity/interaction/password/UpdatePasswordHandler.test.ts b/test/unit/identity/interaction/password/UpdatePasswordHandler.test.ts index 76023330b..06fd89b80 100644 --- a/test/unit/identity/interaction/password/UpdatePasswordHandler.test.ts +++ b/test/unit/identity/interaction/password/UpdatePasswordHandler.test.ts @@ -1,67 +1,74 @@ -import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; -import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; import { UpdatePasswordHandler } from '../../../../../src/identity/interaction/password/UpdatePasswordHandler'; -import { PASSWORD_METHOD } from '../../../../../src/identity/interaction/password/util/PasswordStore'; -import type { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore'; -import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; +import type { PasswordIdRoute } from '../../../../../src/identity/interaction/password/util/PasswordIdRoute'; +import { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore'; +import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError'; describe('An UpdatePasswordHandler', (): void => { - let account: Account; let json: unknown; + const id = 'id'; + const accountId = 'accountId'; const email = 'email@example.com'; const target = { path: 'http://example.com/.account/password' }; const oldPassword = 'oldPassword!'; const newPassword = 'newPassword!'; - let accountStore: jest.Mocked; - let passwordStore: jest.Mocked; + let store: jest.Mocked; + let route: jest.Mocked; let handler: UpdatePasswordHandler; beforeEach(async(): Promise => { json = { oldPassword, newPassword }; - account = createAccount(); - account.logins[PASSWORD_METHOD] = { [email]: target.path }; - accountStore = mockAccountStore(account); - - passwordStore = { + store = { + get: jest.fn().mockResolvedValue({ email, accountId }), authenticate: jest.fn(), update: jest.fn(), - } as any; + } satisfies Partial as any; - handler = new UpdatePasswordHandler(accountStore, passwordStore); + route = { + getPath: jest.fn().mockReturnValue(''), + matchPath: jest.fn().mockReturnValue({ accountId, passwordId: id }), + }; + + handler = new UpdatePasswordHandler(store, route); }); it('requires specific input fields.', async(): Promise => { await expect(handler.getView()).resolves.toEqual({ json: { fields: { - oldPassword: { - required: true, - type: 'string', - }, - newPassword: { - required: true, - type: 'string', - }, + oldPassword: { required: true, type: 'string' }, + newPassword: { required: true, type: 'string' }, }, }, }); }); it('updates the password.', async(): Promise => { - await expect(handler.handle({ json, accountId: account.id, target } as any)).resolves.toEqual({ json: {}}); - expect(passwordStore.authenticate).toHaveBeenCalledTimes(1); - expect(passwordStore.authenticate).toHaveBeenLastCalledWith(email, oldPassword); - expect(passwordStore.update).toHaveBeenCalledTimes(1); - expect(passwordStore.update).toHaveBeenLastCalledWith(email, newPassword); + await expect(handler.handle({ json, accountId, target } as any)).resolves.toEqual({ json: {}}); + expect(store.get).toHaveBeenCalledTimes(1); + expect(store.get).toHaveBeenLastCalledWith(id); + expect(store.authenticate).toHaveBeenCalledTimes(1); + expect(store.authenticate).toHaveBeenLastCalledWith(email, oldPassword); + expect(store.update).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenLastCalledWith(id, newPassword); }); it('errors if authentication fails.', async(): Promise => { - passwordStore.authenticate.mockRejectedValueOnce(new Error('bad data')); - await expect(handler.handle({ json, accountId: account.id, target } as any)) + store.authenticate.mockRejectedValueOnce(new Error('bad data')); + await expect(handler.handle({ json, accountId, target } as any)) .rejects.toThrow('Old password is invalid.'); - expect(passwordStore.authenticate).toHaveBeenCalledTimes(1); - expect(passwordStore.authenticate).toHaveBeenLastCalledWith(email, oldPassword); - expect(passwordStore.update).toHaveBeenCalledTimes(0); + expect(store.get).toHaveBeenCalledTimes(1); + expect(store.get).toHaveBeenLastCalledWith(id); + expect(store.authenticate).toHaveBeenCalledTimes(1); + expect(store.authenticate).toHaveBeenLastCalledWith(email, oldPassword); + expect(store.update).toHaveBeenCalledTimes(0); + }); + + it('throws a 404 if the authenticated accountId is not the owner.', async(): Promise => { + await expect(handler.handle({ target, json, accountId: 'otherId' } as any)).rejects.toThrow(NotFoundHttpError); + expect(store.get).toHaveBeenCalledTimes(1); + expect(store.get).toHaveBeenLastCalledWith(id); + expect(store.authenticate).toHaveBeenCalledTimes(0); + expect(store.update).toHaveBeenCalledTimes(0); }); }); diff --git a/test/unit/identity/interaction/password/util/BasePasswordStore.test.ts b/test/unit/identity/interaction/password/util/BasePasswordStore.test.ts index a3b07623a..0a3d24f5c 100644 --- a/test/unit/identity/interaction/password/util/BasePasswordStore.test.ts +++ b/test/unit/identity/interaction/password/util/BasePasswordStore.test.ts @@ -1,87 +1,168 @@ +import { hash } from 'bcryptjs'; +import { + ACCOUNT_TYPE, + AccountLoginStorage, +} from '../../../../../../src/identity/interaction/account/util/LoginStorage'; import { BasePasswordStore } from '../../../../../../src/identity/interaction/password/util/BasePasswordStore'; -import type { - LoginPayload, -} from '../../../../../../src/identity/interaction/password/util/BasePasswordStore'; -import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage'; +import { InternalServerError } from '../../../../../../src/util/errors/InternalServerError'; + +const STORAGE_TYPE = 'password'; describe('A BasePasswordStore', (): void => { - const email = 'test@example.com'; + const id = 'id'; + const email = 'TesT@example.com'; + const lowercase = 'test@example.com'; const accountId = 'accountId'; const password = 'password!'; - let storage: jest.Mocked>; + let payload: Record; + let storage: jest.Mocked>; let store: BasePasswordStore; beforeEach(async(): Promise => { - const map = new Map(); + payload = { id, email: lowercase, accountId, password: await hash(password, 10), verified: true }; + storage = { - get: jest.fn((id: string): any => map.get(id)), - set: jest.fn((id: string, value: any): any => map.set(id, value)), - delete: jest.fn((id: string): any => map.delete(id)), - } as any; + defineType: jest.fn().mockResolvedValue({}), + createIndex: jest.fn().mockResolvedValue({}), + create: jest.fn().mockResolvedValue(payload), + has: jest.fn().mockResolvedValue(true), + get: jest.fn().mockResolvedValue(payload), + find: jest.fn().mockResolvedValue([ payload ]), + setField: jest.fn(), + delete: jest.fn(), + } satisfies Partial> as any; store = new BasePasswordStore(storage); }); + it('defines the type and indexes in the storage.', async(): Promise => { + await expect(store.handle()).resolves.toBeUndefined(); + expect(storage.defineType).toHaveBeenCalledTimes(1); + expect(storage.defineType).toHaveBeenLastCalledWith(STORAGE_TYPE, { + email: 'string', + password: 'string', + verified: 'boolean', + accountId: `id:${ACCOUNT_TYPE}`, + }, true); + expect(storage.createIndex).toHaveBeenCalledTimes(2); + expect(storage.createIndex).toHaveBeenCalledWith(STORAGE_TYPE, 'accountId'); + expect(storage.createIndex).toHaveBeenCalledWith(STORAGE_TYPE, 'email'); + }); + + it('can only initialize once.', async(): Promise => { + await expect(store.handle()).resolves.toBeUndefined(); + await expect(store.handle()).resolves.toBeUndefined(); + expect(storage.defineType).toHaveBeenCalledTimes(1); + }); + + it('throws an error if defining the type goes wrong.', async(): Promise => { + storage.defineType.mockRejectedValueOnce(new Error('bad data')); + await expect(store.handle()).rejects.toThrow(InternalServerError); + }); + it('can create logins.', async(): Promise => { - await expect(store.create(email, accountId, password)).resolves.toBeUndefined(); + storage.find.mockResolvedValueOnce([]); + await expect(store.create(email, accountId, password)).resolves.toEqual(id); + expect(storage.find).toHaveBeenCalledTimes(1); + expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { email: lowercase }); + expect(storage.create).toHaveBeenCalledTimes(1); + expect(storage.create).toHaveBeenLastCalledWith(STORAGE_TYPE, + { accountId, verified: false, email: lowercase, password: expect.any(String) }); }); it('errors when creating a second login for an email.', async(): Promise => { - await expect(store.create(email, accountId, password)).resolves.toBeUndefined(); - await expect(store.create(email, accountId, 'diffPass')) - .rejects.toThrow('here already is a login for this e-mail address.'); + await expect(store.create(email, accountId, password)) + .rejects.toThrow('There already is a login for this e-mail address.'); + expect(storage.find).toHaveBeenCalledTimes(1); + expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { email: lowercase }); + expect(storage.create).toHaveBeenCalledTimes(0); }); - it('errors when authenticating a non-existent login.', async(): Promise => { - await expect(store.authenticate(email, password)).rejects.toThrow('Login does not exist.'); + it('can get the login information.', async(): Promise => { + await expect(store.get(id)).resolves.toEqual({ accountId, email: lowercase }); + expect(storage.get).toHaveBeenCalledTimes(1); + expect(storage.get).toHaveBeenLastCalledWith(STORAGE_TYPE, id); }); - it('errors when authenticating an unverified login.', async(): Promise => { - await expect(store.create(email, accountId, password)).resolves.toBeUndefined(); - await expect(store.authenticate(email, password)).rejects.toThrow('Login still needs to be verified.'); + it('returns undefined if there is no matching login.', async(): Promise => { + storage.get.mockResolvedValueOnce(undefined); + await expect(store.get(id)).resolves.toBeUndefined(); + expect(storage.get).toHaveBeenCalledTimes(1); + expect(storage.get).toHaveBeenLastCalledWith(STORAGE_TYPE, id); + }); + + it('can find the login information by email.', async(): Promise => { + await expect(store.findByEmail(email)).resolves.toEqual({ id, accountId }); + expect(storage.find).toHaveBeenCalledTimes(1); + expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { email: lowercase }); + }); + + it('can find all logins associated with an account.', async(): Promise => { + await expect(store.findByAccount(accountId)).resolves.toEqual([{ id, email: lowercase }]); + expect(storage.find).toHaveBeenCalledTimes(1); + expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { accountId }); }); it('errors when verifying a non-existent login.', async(): Promise => { - await expect(store.confirmVerification(email)).rejects.toThrow('Login does not exist.'); + storage.has.mockResolvedValueOnce(false); + await expect(store.confirmVerification(id)).rejects.toThrow('Login does not exist.'); + expect(storage.has).toHaveBeenCalledTimes(1); + expect(storage.has).toHaveBeenLastCalledWith(STORAGE_TYPE, id); + expect(storage.setField).toHaveBeenCalledTimes(0); + }); + + it('can verify a login.', async(): Promise => { + await expect(store.confirmVerification(id)).resolves.toBeUndefined(); + expect(storage.has).toHaveBeenCalledTimes(1); + expect(storage.has).toHaveBeenLastCalledWith(STORAGE_TYPE, id); + expect(storage.setField).toHaveBeenCalledTimes(1); + expect(storage.setField).toHaveBeenLastCalledWith(STORAGE_TYPE, id, 'verified', true); + }); + + it('errors when authenticating a non-existent login.', async(): Promise => { + storage.find.mockResolvedValueOnce([]); + await expect(store.authenticate(email, password)).rejects.toThrow('Invalid email/password combination.'); + expect(storage.find).toHaveBeenCalledTimes(1); + expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { email: lowercase }); + }); + + it('errors when authenticating an unverified login.', async(): Promise => { + storage.find.mockResolvedValueOnce([ { ...payload, verified: false } as any ]); + await expect(store.authenticate(email, password)).rejects.toThrow('Login still needs to be verified.'); + expect(storage.find).toHaveBeenCalledTimes(1); + expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { email: lowercase }); }); it('errors when authenticating with the wrong password.', async(): Promise => { - await expect(store.create(email, accountId, password)).resolves.toBeUndefined(); - await expect(store.confirmVerification(email)).resolves.toBeUndefined(); - await expect(store.authenticate(email, 'wrongPassword')).rejects.toThrow('Incorrect password.'); + await expect(store.authenticate(email, 'wrongPassword')).rejects.toThrow('Invalid email/password combination.'); + expect(storage.find).toHaveBeenCalledTimes(1); + expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { email: lowercase }); }); it('can authenticate.', async(): Promise => { - await expect(store.create(email, accountId, password)).resolves.toBeUndefined(); - await expect(store.confirmVerification(email)).resolves.toBeUndefined(); - await expect(store.authenticate(email, password)).resolves.toBe(accountId); + await expect(store.authenticate(email, password)).resolves.toEqual({ accountId, id }); + expect(storage.find).toHaveBeenCalledTimes(1); + expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { email: lowercase }); }); it('errors when changing the password of a non-existent account.', async(): Promise => { - await expect(store.update(email, password)).rejects.toThrow('Login does not exist.'); + storage.has.mockResolvedValueOnce(false); + await expect(store.update(id, password)).rejects.toThrow('Login does not exist.'); + expect(storage.has).toHaveBeenCalledTimes(1); + expect(storage.has).toHaveBeenLastCalledWith(STORAGE_TYPE, id); + expect(storage.setField).toHaveBeenCalledTimes(0); }); it('can change the password.', async(): Promise => { const newPassword = 'newPassword!'; - await expect(store.create(email, accountId, password)).resolves.toBeUndefined(); - await expect(store.confirmVerification(email)).resolves.toBeUndefined(); - await expect(store.update(email, newPassword)).resolves.toBeUndefined(); - await expect(store.authenticate(email, newPassword)).resolves.toBe(accountId); - }); - - it('can get the accountId.', async(): Promise => { - await expect(store.create(email, accountId, password)).resolves.toBeUndefined(); - await expect(store.get(email)).resolves.toEqual(accountId); + await expect(store.update(id, newPassword)).resolves.toBeUndefined(); + expect(storage.has).toHaveBeenCalledTimes(1); + expect(storage.has).toHaveBeenLastCalledWith(STORAGE_TYPE, id); + expect(storage.setField).toHaveBeenCalledTimes(1); + expect(storage.setField).toHaveBeenLastCalledWith(STORAGE_TYPE, id, 'password', expect.any(String)); }); it('can delete a login.', async(): Promise => { - await expect(store.create(email, accountId, password)).resolves.toBeUndefined(); - await expect(store.delete(email)).resolves.toBe(true); - await expect(store.authenticate(email, password)).rejects.toThrow('Login does not exist.'); - await expect(store.get(accountId)).resolves.toBeUndefined(); - }); - - it('does nothing when deleting non-existent login.', async(): Promise => { - await expect(store.delete(email)).resolves.toBe(false); + await expect(store.delete(email)).resolves.toBeUndefined(); }); }); diff --git a/test/unit/identity/interaction/pod/CreatePodHandler.test.ts b/test/unit/identity/interaction/pod/CreatePodHandler.test.ts index 215b1fc44..281ff02bd 100644 --- a/test/unit/identity/interaction/pod/CreatePodHandler.test.ts +++ b/test/unit/identity/interaction/pod/CreatePodHandler.test.ts @@ -1,15 +1,16 @@ -import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; -import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; import { CreatePodHandler } from '../../../../../src/identity/interaction/pod/CreatePodHandler'; -import type { PodStore } from '../../../../../src/identity/interaction/pod/util/PodStore'; -import type { WebIdStore } from '../../../../../src/identity/interaction/webid/util/WebIdStore'; +import type { PodIdRoute } from '../../../../../src/identity/interaction/pod/PodIdRoute'; +import { PodStore } from '../../../../../src/identity/interaction/pod/util/PodStore'; +import { WebIdStore } from '../../../../../src/identity/interaction/webid/util/WebIdStore'; +import type { WebIdLinkRoute } from '../../../../../src/identity/interaction/webid/WebIdLinkRoute'; import type { IdentifierGenerator } from '../../../../../src/pods/generate/IdentifierGenerator'; -import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; describe('A CreatePodHandler', (): void => { const name = 'name'; const webId = 'http://example.com/other/webId#me'; const accountId = 'accountId'; + const podId = 'podId'; + const webIdLink = 'webIdLink'; let json: unknown; const baseUrl = 'http://example.com/'; const relativeWebIdPath = '/profile/card#me'; @@ -18,8 +19,9 @@ describe('A CreatePodHandler', (): void => { const webIdResource = 'http://example.com/.account/webID'; const podResource = 'http://example.com/.account/pod'; let identifierGenerator: jest.Mocked; - let accountStore: jest.Mocked; let webIdStore: jest.Mocked; + let webIdLinkRoute: jest.Mocked; + let podIdRoute: jest.Mocked; let podStore: jest.Mocked; let handler: CreatePodHandler; @@ -33,55 +35,70 @@ describe('A CreatePodHandler', (): void => { extractPod: jest.fn(), }; - accountStore = mockAccountStore(); - accountStore.get.mockImplementation(async(id: string): Promise => createAccount(id)); - webIdStore = { - get: jest.fn(), - add: jest.fn().mockResolvedValue(webIdResource), + isLinked: jest.fn().mockResolvedValue(false), + create: jest.fn().mockResolvedValue(webIdLink), delete: jest.fn(), - }; + } satisfies Partial as any; podStore = { - create: jest.fn().mockResolvedValue(podResource), + create: jest.fn().mockResolvedValue(podId), + findPods: jest.fn().mockResolvedValue([{ id: podId, baseUrl: podUrl }]), + } satisfies Partial as any; + + webIdLinkRoute = { + getPath: jest.fn().mockReturnValue(webIdResource), + matchPath: jest.fn(), }; - handler = new CreatePodHandler( - { accountStore, webIdStore, podStore, baseUrl, relativeWebIdPath, identifierGenerator, allowRoot: false }, - ); + podIdRoute = { + getPath: jest.fn().mockReturnValue(podResource), + matchPath: jest.fn(), + }; + + handler = new CreatePodHandler({ + webIdStore, + podStore, + baseUrl, + relativeWebIdPath, + identifierGenerator, + webIdLinkRoute, + podIdRoute, + allowRoot: false, + }); }); - it('requires specific input fields.', async(): Promise => { - await expect(handler.getView()).resolves.toEqual({ + it('returns the required input fields and known pods.', async(): Promise => { + await expect(handler.getView({ accountId } as any)).resolves.toEqual({ json: { + pods: { + [podUrl]: podResource, + }, fields: { - name: { - required: true, - type: 'string', - }, + name: { required: true, type: 'string' }, settings: { required: false, type: 'object', - fields: { - webId: { - required: false, - type: 'string', - }, - }, + fields: { webId: { required: false, type: 'string' }}, }, }, }, }); + + expect(podStore.findPods).toHaveBeenCalledTimes(1); + expect(podStore.findPods).toHaveBeenLastCalledWith(accountId); }); it('generates a pod and WebID.', async(): Promise => { await expect(handler.handle({ json, accountId } as any)).resolves.toEqual({ json: { pod: podUrl, webId: generatedWebId, podResource, webIdResource, }}); - expect(webIdStore.add).toHaveBeenCalledTimes(1); - expect(webIdStore.add).toHaveBeenLastCalledWith(generatedWebId, await accountStore.get.mock.results[0].value); + expect(webIdStore.isLinked).toHaveBeenCalledTimes(1); + expect(webIdStore.isLinked).toHaveBeenLastCalledWith(generatedWebId, accountId); + expect(webIdStore.create).toHaveBeenCalledTimes(1); + expect(webIdStore.create).toHaveBeenLastCalledWith(generatedWebId, accountId); expect(podStore.create).toHaveBeenCalledTimes(1); - expect(podStore.create).toHaveBeenLastCalledWith(await accountStore.get.mock.results[0].value, { + expect(podStore.create).toHaveBeenLastCalledWith(accountId, { base: { path: podUrl }, webId: generatedWebId, oidcIssuer: baseUrl, @@ -94,21 +111,22 @@ describe('A CreatePodHandler', (): void => { await expect(handler.handle({ json, accountId } as any)).resolves.toEqual({ json: { pod: podUrl, webId, podResource, }}); - expect(webIdStore.add).toHaveBeenCalledTimes(0); + expect(webIdStore.isLinked).toHaveBeenCalledTimes(0); + expect(webIdStore.create).toHaveBeenCalledTimes(0); expect(podStore.create).toHaveBeenCalledTimes(1); - expect(podStore.create).toHaveBeenLastCalledWith(await accountStore.get.mock.results[0].value, { + expect(podStore.create).toHaveBeenLastCalledWith(accountId, { base: { path: podUrl }, webId, }, false); }); it('errors if the account is already linked to the WebID that would be generated.', async(): Promise => { - const account = createAccount(); - account.webIds[generatedWebId] = 'http://example.com/resource'; - accountStore.get.mockResolvedValueOnce(account); + webIdStore.isLinked.mockResolvedValueOnce(true); await expect(handler.handle({ json, accountId } as any)) .rejects.toThrow(`${generatedWebId} is already registered to this account.`); - expect(webIdStore.add).toHaveBeenCalledTimes(0); + expect(webIdStore.isLinked).toHaveBeenCalledTimes(1); + expect(webIdStore.isLinked).toHaveBeenLastCalledWith(generatedWebId, accountId); + expect(webIdStore.create).toHaveBeenCalledTimes(0); expect(podStore.create).toHaveBeenCalledTimes(0); }); @@ -118,42 +136,44 @@ describe('A CreatePodHandler', (): void => { await expect(handler.handle({ json, accountId } as any)).rejects.toBe(error); - expect(webIdStore.add).toHaveBeenCalledTimes(1); - expect(webIdStore.add).toHaveBeenLastCalledWith(generatedWebId, await accountStore.get.mock.results[0].value); + expect(webIdStore.create).toHaveBeenCalledTimes(1); + expect(webIdStore.create).toHaveBeenLastCalledWith(generatedWebId, accountId); expect(podStore.create).toHaveBeenCalledTimes(1); - expect(podStore.create).toHaveBeenLastCalledWith(await accountStore.get.mock.results[0].value, { + expect(podStore.create).toHaveBeenLastCalledWith(accountId, { base: { path: podUrl }, webId: generatedWebId, oidcIssuer: baseUrl, }, false); expect(webIdStore.delete).toHaveBeenCalledTimes(1); - expect(webIdStore.add).toHaveBeenLastCalledWith(generatedWebId, await accountStore.get.mock.results[1].value); + expect(webIdStore.delete).toHaveBeenLastCalledWith(webIdLink); }); describe('allowing root pods', (): void => { beforeEach(async(): Promise => { - handler = new CreatePodHandler( - { accountStore, webIdStore, podStore, baseUrl, relativeWebIdPath, identifierGenerator, allowRoot: true }, - ); + handler = new CreatePodHandler({ + webIdStore, + podStore, + baseUrl, + relativeWebIdPath, + identifierGenerator, + webIdLinkRoute, + podIdRoute, + allowRoot: true, + }); }); it('does not require a name.', async(): Promise => { - await expect(handler.getView()).resolves.toEqual({ + await expect(handler.getView({ accountId } as any)).resolves.toEqual({ json: { + pods: { + [podUrl]: podResource, + }, fields: { - name: { - required: false, - type: 'string', - }, + name: { required: false, type: 'string' }, settings: { required: false, type: 'object', - fields: { - webId: { - required: false, - type: 'string', - }, - }, + fields: { webId: { required: false, type: 'string' }}, }, }, }, @@ -164,11 +184,10 @@ describe('A CreatePodHandler', (): void => { await expect(handler.handle({ json: {}, accountId } as any)).resolves.toEqual({ json: { pod: baseUrl, webId: `${baseUrl}profile/card#me`, podResource, webIdResource, }}); - expect(webIdStore.add).toHaveBeenCalledTimes(1); - expect(webIdStore.add) - .toHaveBeenLastCalledWith(`${baseUrl}profile/card#me`, await accountStore.get.mock.results[0].value); + expect(webIdStore.create).toHaveBeenCalledTimes(1); + expect(webIdStore.create).toHaveBeenLastCalledWith(`${baseUrl}profile/card#me`, accountId); expect(podStore.create).toHaveBeenCalledTimes(1); - expect(podStore.create).toHaveBeenLastCalledWith(await accountStore.get.mock.results[0].value, { + expect(podStore.create).toHaveBeenLastCalledWith(accountId, { base: { path: baseUrl }, webId: `${baseUrl}profile/card#me`, oidcIssuer: baseUrl, diff --git a/test/unit/identity/interaction/pod/util/BasePodStore.test.ts b/test/unit/identity/interaction/pod/util/BasePodStore.test.ts index dca7ec050..3909b1da4 100644 --- a/test/unit/identity/interaction/pod/util/BasePodStore.test.ts +++ b/test/unit/identity/interaction/pod/util/BasePodStore.test.ts @@ -1,50 +1,97 @@ -import type { Account } from '../../../../../../src/identity/interaction/account/util/Account'; -import type { AccountStore } from '../../../../../../src/identity/interaction/account/util/AccountStore'; -import type { PodIdRoute } from '../../../../../../src/identity/interaction/pod/PodIdRoute'; +import { + ACCOUNT_TYPE, + AccountLoginStorage, +} from '../../../../../../src/identity/interaction/account/util/LoginStorage'; import { BasePodStore } from '../../../../../../src/identity/interaction/pod/util/BasePodStore'; import type { PodManager } from '../../../../../../src/pods/PodManager'; import type { PodSettings } from '../../../../../../src/pods/settings/PodSettings'; -import { createAccount, mockAccountStore } from '../../../../../util/AccountUtil'; +import { InternalServerError } from '../../../../../../src/util/errors/InternalServerError'; + +const STORAGE_TYPE = 'pod'; describe('A BasePodStore', (): void => { - let account: Account; - const settings: PodSettings = { webId: 'http://example.com/card#me', base: { path: 'http://example.com/foo' }}; - const route: PodIdRoute = { - getPath: (): string => 'http://example.com/.account/resource', - matchPath: (): any => ({}), - }; - let accountStore: jest.Mocked; + const accountId = 'accountId'; + const id = 'id'; + const baseUrl = 'http://example.com/foo/'; + const settings: PodSettings = { webId: 'http://example.com/card#me', base: { path: baseUrl }}; + let storage: jest.Mocked>; let manager: jest.Mocked; let store: BasePodStore; beforeEach(async(): Promise => { - account = createAccount(); - - accountStore = mockAccountStore(createAccount()); + storage = { + defineType: jest.fn().mockResolvedValue({}), + createIndex: jest.fn().mockResolvedValue({}), + create: jest.fn().mockResolvedValue({ id, baseUrl, accountId }), + find: jest.fn().mockResolvedValue([{ id, baseUrl, accountId }]), + delete: jest.fn(), + } satisfies Partial> as any; manager = { createPod: jest.fn(), }; - store = new BasePodStore(accountStore, route, manager); + store = new BasePodStore(storage, manager); + }); + + it('defines the type and indexes in the storage.', async(): Promise => { + await expect(store.handle()).resolves.toBeUndefined(); + expect(storage.defineType).toHaveBeenCalledTimes(1); + expect(storage.defineType).toHaveBeenLastCalledWith(STORAGE_TYPE, { + baseUrl: 'string', + accountId: `id:${ACCOUNT_TYPE}`, + }, false); + expect(storage.createIndex).toHaveBeenCalledTimes(2); + expect(storage.createIndex).toHaveBeenCalledWith(STORAGE_TYPE, 'accountId'); + expect(storage.createIndex).toHaveBeenCalledWith(STORAGE_TYPE, 'baseUrl'); + }); + + it('can only initialize once.', async(): Promise => { + await expect(store.handle()).resolves.toBeUndefined(); + await expect(store.handle()).resolves.toBeUndefined(); + expect(storage.defineType).toHaveBeenCalledTimes(1); + }); + + it('throws an error if defining the type goes wrong.', async(): Promise => { + storage.defineType.mockRejectedValueOnce(new Error('bad data')); + await expect(store.handle()).rejects.toThrow(InternalServerError); }); it('calls the pod manager to create a pod.', async(): Promise => { - await expect(store.create(account, settings, false)).resolves.toBe('http://example.com/.account/resource'); + await expect(store.create(accountId, settings, false)).resolves.toBe(id); + expect(storage.create).toHaveBeenCalledTimes(1); + expect(storage.create).toHaveBeenLastCalledWith(STORAGE_TYPE, { accountId, baseUrl }); expect(manager.createPod).toHaveBeenCalledTimes(1); expect(manager.createPod).toHaveBeenLastCalledWith(settings, false); - expect(accountStore.update).toHaveBeenCalledTimes(1); - expect(accountStore.update).toHaveBeenLastCalledWith(account); - expect(account.pods['http://example.com/foo']).toBe('http://example.com/.account/resource'); }); - it('does not update the account if something goes wrong.', async(): Promise => { + it('reverts the storage changes if something goes wrong.', async(): Promise => { manager.createPod.mockRejectedValueOnce(new Error('bad data')); - await expect(store.create(account, settings, false)).rejects.toThrow('Pod creation failed: bad data'); + await expect(store.create(accountId, settings, false)).rejects.toThrow('Pod creation failed: bad data'); + expect(storage.create).toHaveBeenCalledTimes(1); + expect(storage.create).toHaveBeenLastCalledWith(STORAGE_TYPE, { accountId, baseUrl }); expect(manager.createPod).toHaveBeenCalledTimes(1); expect(manager.createPod).toHaveBeenLastCalledWith(settings, false); - expect(accountStore.update).toHaveBeenCalledTimes(2); - expect(accountStore.update).toHaveBeenLastCalledWith(account); - expect(account.pods).toEqual({}); + expect(storage.delete).toHaveBeenCalledTimes(1); + expect(storage.delete).toHaveBeenLastCalledWith(STORAGE_TYPE, id); + }); + + it('can find all the pods for an account.', async(): Promise => { + await expect(store.findPods(accountId)).resolves.toEqual([{ id, baseUrl }]); + expect(storage.find).toHaveBeenCalledTimes(1); + expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { accountId }); + }); + + it('can find the account that created a pod.', async(): Promise => { + await expect(store.findAccount(baseUrl)).resolves.toEqual(accountId); + expect(storage.find).toHaveBeenCalledTimes(1); + expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { baseUrl }); + }); + + it('returns undefined if there is no associated account.', async(): Promise => { + storage.find.mockResolvedValueOnce([]); + await expect(store.findAccount(baseUrl)).resolves.toBeUndefined(); + expect(storage.find).toHaveBeenCalledTimes(1); + expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { baseUrl }); }); }); diff --git a/test/unit/identity/interaction/webid/LinkWebIdHandler.test.ts b/test/unit/identity/interaction/webid/LinkWebIdHandler.test.ts index a73a27400..1c3efbff4 100644 --- a/test/unit/identity/interaction/webid/LinkWebIdHandler.test.ts +++ b/test/unit/identity/interaction/webid/LinkWebIdHandler.test.ts @@ -1,25 +1,24 @@ -import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; -import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; +import { PodStore } from '../../../../../src/identity/interaction/pod/util/PodStore'; import { LinkWebIdHandler } from '../../../../../src/identity/interaction/webid/LinkWebIdHandler'; -import type { WebIdStore } from '../../../../../src/identity/interaction/webid/util/WebIdStore'; +import { WebIdStore } from '../../../../../src/identity/interaction/webid/util/WebIdStore'; import type { WebIdLinkRoute } from '../../../../../src/identity/interaction/webid/WebIdLinkRoute'; -import type { OwnershipValidator } from '../../../../../src/identity/ownership/OwnershipValidator'; +import { OwnershipValidator } from '../../../../../src/identity/ownership/OwnershipValidator'; +import { StorageLocationStrategy } from '../../../../../src/server/description/StorageLocationStrategy'; import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError'; -import type { IdentifierStrategy } from '../../../../../src/util/identifiers/IdentifierStrategy'; -import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; describe('A LinkWebIdHandler', (): void => { - let account: Account; + const id = 'id'; const accountId = 'accountId'; - const webId = 'http://example.com/profile/card#me'; + const webId = 'http://example.com/pod/profile/card#me'; let json: unknown; const resource = 'http://example.com/.account/link'; const baseUrl = 'http://example.com/'; + const podUrl = 'http://example.com/pod/'; let ownershipValidator: jest.Mocked; - let accountStore: jest.Mocked; + let podStore: jest.Mocked; let webIdStore: jest.Mocked; let webIdRoute: jest.Mocked; - let identifierStrategy: jest.Mocked; + let storageStrategy: jest.Mocked; let handler: LinkWebIdHandler; beforeEach(async(): Promise => { @@ -27,81 +26,91 @@ describe('A LinkWebIdHandler', (): void => { ownershipValidator = { handleSafe: jest.fn(), - } as any; + } satisfies Partial as any; - account = createAccount(); - accountStore = mockAccountStore(account); + podStore = { + findAccount: jest.fn().mockResolvedValue(accountId), + } satisfies Partial as any; webIdStore = { - add: jest.fn().mockResolvedValue(resource), - } as any; + create: jest.fn().mockResolvedValue(id), + isLinked: jest.fn().mockResolvedValue(false), + findLinks: jest.fn().mockResolvedValue([{ id, webId }]), + } satisfies Partial as any; - identifierStrategy = { - contains: jest.fn().mockReturnValue(true), - } as any; + webIdRoute = { + getPath: jest.fn().mockReturnValue(resource), + matchPath: jest.fn(), + }; + + storageStrategy = { + getStorageIdentifier: jest.fn().mockReturnValue({ path: podUrl }), + } satisfies Partial as any; handler = new LinkWebIdHandler({ - accountStore, - identifierStrategy, + podStore, webIdRoute, webIdStore, ownershipValidator, baseUrl, + storageStrategy, }); }); - it('requires a WebID as input.', async(): Promise => { - await expect(handler.getView()).resolves.toEqual({ + it('requires a WebID as input and returns the linked WebIds.', async(): Promise => { + await expect(handler.getView({ accountId } as any)).resolves.toEqual({ json: { + webIdLinks: { + [webId]: resource, + }, fields: { - webId: { - required: true, - type: 'string', - }, + webId: { required: true, type: 'string' }, }, }, }); + expect(webIdStore.findLinks).toHaveBeenCalledTimes(1); + expect(webIdStore.findLinks).toHaveBeenLastCalledWith(accountId); }); - it('links the WebID.', async(): Promise => { + it('links the WebID if the account created the pod it is in.', async(): Promise => { await expect(handler.handle({ accountId, json } as any)).resolves.toEqual({ json: { resource, webId, oidcIssuer: baseUrl }, }); - expect(webIdStore.add).toHaveBeenCalledTimes(1); - expect(webIdStore.add).toHaveBeenLastCalledWith(webId, account); - }); - - it('throws an error if the WebID is already registered.', async(): Promise => { - account.webIds[webId] = resource; - await expect(handler.handle({ accountId, json } as any)).rejects.toThrow(BadRequestHttpError); - expect(webIdStore.add).toHaveBeenCalledTimes(0); - }); - - it('checks if the WebID is in a pod owned by the account.', async(): Promise => { - account.pods['http://example.com/.account/pod/'] = resource; - await expect(handler.handle({ accountId, json } as any)).resolves.toEqual({ - json: { resource, webId, oidcIssuer: baseUrl }, - }); - expect(identifierStrategy.contains).toHaveBeenCalledTimes(1); - expect(identifierStrategy.contains) - .toHaveBeenCalledWith({ path: 'http://example.com/.account/pod/' }, { path: webId }, true); + expect(webIdStore.isLinked).toHaveBeenCalledTimes(1); + expect(webIdStore.isLinked).toHaveBeenLastCalledWith(webId, accountId); + expect(storageStrategy.getStorageIdentifier).toHaveBeenCalledTimes(1); + expect(storageStrategy.getStorageIdentifier).toHaveBeenLastCalledWith({ path: webId }); + expect(podStore.findAccount).toHaveBeenCalledTimes(1); + expect(podStore.findAccount).toHaveBeenLastCalledWith(podUrl); + expect(webIdStore.create).toHaveBeenCalledTimes(1); + expect(webIdStore.create).toHaveBeenLastCalledWith(webId, accountId); expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(0); }); - it('calls the ownership validator if none of the pods contain the WebId.', async(): Promise => { - identifierStrategy.contains.mockReturnValue(false); - account.pods['http://example.com/.account/pod/'] = resource; - account.pods['http://example.com/.account/pod2/'] = resource; + it('throws an error if the WebID is already registered to this account.', async(): Promise => { + webIdStore.isLinked.mockResolvedValueOnce(true); + await expect(handler.handle({ accountId, json } as any)).rejects.toThrow(BadRequestHttpError); + expect(webIdStore.isLinked).toHaveBeenCalledTimes(1); + expect(webIdStore.isLinked).toHaveBeenLastCalledWith(webId, accountId); + expect(storageStrategy.getStorageIdentifier).toHaveBeenCalledTimes(0); + expect(podStore.findAccount).toHaveBeenCalledTimes(0); + expect(webIdStore.create).toHaveBeenCalledTimes(0); + }); + it('calls the ownership validator if the account did not create the pod the WebID is in.', async(): Promise => { + podStore.findAccount.mockResolvedValueOnce(undefined); await expect(handler.handle({ accountId, json } as any)).resolves.toEqual({ json: { resource, webId, oidcIssuer: baseUrl }, }); - expect(identifierStrategy.contains).toHaveBeenCalledTimes(2); - expect(identifierStrategy.contains) - .toHaveBeenCalledWith({ path: 'http://example.com/.account/pod/' }, { path: webId }, true); - expect(identifierStrategy.contains) - .toHaveBeenCalledWith({ path: 'http://example.com/.account/pod2/' }, { path: webId }, true); + expect(webIdStore.isLinked).toHaveBeenCalledTimes(1); + expect(webIdStore.isLinked).toHaveBeenLastCalledWith(webId, accountId); + expect(storageStrategy.getStorageIdentifier).toHaveBeenCalledTimes(1); + expect(storageStrategy.getStorageIdentifier).toHaveBeenLastCalledWith({ path: webId }); + expect(podStore.findAccount).toHaveBeenCalledTimes(1); + expect(podStore.findAccount).toHaveBeenLastCalledWith(podUrl); expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); + expect(webIdStore.create).toHaveBeenCalledTimes(1); + expect(webIdStore.create).toHaveBeenLastCalledWith(webId, accountId); }); }); diff --git a/test/unit/identity/interaction/webid/UnlinkWebIdHandler.test.ts b/test/unit/identity/interaction/webid/UnlinkWebIdHandler.test.ts index 97a02b69c..dd4a4bed6 100644 --- a/test/unit/identity/interaction/webid/UnlinkWebIdHandler.test.ts +++ b/test/unit/identity/interaction/webid/UnlinkWebIdHandler.test.ts @@ -1,42 +1,41 @@ -import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; -import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; import { UnlinkWebIdHandler } from '../../../../../src/identity/interaction/webid/UnlinkWebIdHandler'; -import type { WebIdStore } from '../../../../../src/identity/interaction/webid/util/WebIdStore'; +import { WebIdStore } from '../../../../../src/identity/interaction/webid/util/WebIdStore'; +import type { WebIdLinkRoute } from '../../../../../src/identity/interaction/webid/WebIdLinkRoute'; import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError'; -import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; describe('A UnlinkWebIdHandler', (): void => { - const resource = 'http://example.com/.account/link'; + const id = 'link'; + const target = { path: 'http://example.com/.account/link' }; const webId = 'http://example.com/.account/card#me'; const accountId = 'accountId'; - let account: Account; - let accountStore: jest.Mocked; - let webIdStore: jest.Mocked; + let store: jest.Mocked; + let route: jest.Mocked; let handler: UnlinkWebIdHandler; beforeEach(async(): Promise => { - account = createAccount(accountId); - account.webIds[webId] = resource; - - accountStore = mockAccountStore(account); - - webIdStore = { - get: jest.fn(), - add: jest.fn(), + store = { + get: jest.fn().mockResolvedValue({ accountId, webId }), delete: jest.fn(), + } satisfies Partial as any; + + route = { + getPath: jest.fn(), + matchPath: jest.fn().mockReturnValue({ accountId, webIdLink: id }), }; - handler = new UnlinkWebIdHandler(accountStore, webIdStore); + handler = new UnlinkWebIdHandler(store, route); }); it('removes the WebID link.', async(): Promise => { - await expect(handler.handle({ target: { path: resource }, accountId } as any)).resolves.toEqual({ json: {}}); - expect(webIdStore.delete).toHaveBeenCalledTimes(1); - expect(webIdStore.delete).toHaveBeenLastCalledWith(webId, account); + await expect(handler.handle({ target, accountId } as any)).resolves.toEqual({ json: {}}); + expect(store.delete).toHaveBeenCalledTimes(1); + expect(store.delete).toHaveBeenLastCalledWith(id); }); - it('errors if there is no matching link resource.', async(): Promise => { - delete account.webIds[webId]; - await expect(handler.handle({ target: { path: resource }, accountId } as any)).rejects.toThrow(NotFoundHttpError); + it('throws a 404 if the authenticated accountId is not the owner.', async(): Promise => { + await expect(handler.handle({ target, accountId: 'otherId' } as any)).rejects.toThrow(NotFoundHttpError); + expect(store.get).toHaveBeenCalledTimes(1); + expect(store.get).toHaveBeenLastCalledWith(id); + expect(store.delete).toHaveBeenCalledTimes(0); }); }); diff --git a/test/unit/identity/interaction/webid/util/BaseWebIdStore.test.ts b/test/unit/identity/interaction/webid/util/BaseWebIdStore.test.ts index f95d0f602..b00010d9f 100644 --- a/test/unit/identity/interaction/webid/util/BaseWebIdStore.test.ts +++ b/test/unit/identity/interaction/webid/util/BaseWebIdStore.test.ts @@ -1,112 +1,91 @@ -import type { Account } from '../../../../../../src/identity/interaction/account/util/Account'; -import type { AccountStore } from '../../../../../../src/identity/interaction/account/util/AccountStore'; +import { ACCOUNT_TYPE, + AccountLoginStorage } from '../../../../../../src/identity/interaction/account/util/LoginStorage'; import { BaseWebIdStore } from '../../../../../../src/identity/interaction/webid/util/BaseWebIdStore'; -import type { WebIdLinkRoute } from '../../../../../../src/identity/interaction/webid/WebIdLinkRoute'; -import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage'; import { BadRequestHttpError } from '../../../../../../src/util/errors/BadRequestHttpError'; -import { createAccount, mockAccountStore } from '../../../../../util/AccountUtil'; +import { InternalServerError } from '../../../../../../src/util/errors/InternalServerError'; + +const STORAGE_TYPE = 'webIdLink'; describe('A BaseWebIdStore', (): void => { + const id = 'id'; const webId = 'http://example.com/card#me'; - let account: Account; - const route: WebIdLinkRoute = { - getPath: (): string => 'http://example.com/.account/resource', - matchPath: (): any => ({}), - }; - let accountStore: jest.Mocked; - let storage: jest.Mocked>; + const accountId = 'accountId'; + let storage: jest.Mocked>; let store: BaseWebIdStore; beforeEach(async(): Promise => { - account = createAccount(); - - accountStore = mockAccountStore(createAccount()); - storage = { - get: jest.fn().mockResolvedValue([ account.id ]), - set: jest.fn(), + defineType: jest.fn().mockResolvedValue({}), + createIndex: jest.fn().mockResolvedValue({}), + get: jest.fn().mockResolvedValue({ webId, accountId }), + create: jest.fn().mockResolvedValue({ id, webId, accountId }), + find: jest.fn().mockResolvedValue([{ id, webId, accountId }]), delete: jest.fn(), - } as any; + } satisfies Partial> as any; - store = new BaseWebIdStore(route, accountStore, storage); + store = new BaseWebIdStore(storage); }); - it('returns the stored account identifiers.', async(): Promise => { - await expect(store.get(webId)).resolves.toEqual([ account.id ]); + it('defines the type and indexes in the storage.', async(): Promise => { + await expect(store.handle()).resolves.toBeUndefined(); + expect(storage.defineType).toHaveBeenCalledTimes(1); + expect(storage.defineType).toHaveBeenLastCalledWith(STORAGE_TYPE, { + webId: 'string', + accountId: `id:${ACCOUNT_TYPE}`, + }, false); + expect(storage.createIndex).toHaveBeenCalledTimes(2); + expect(storage.createIndex).toHaveBeenCalledWith(STORAGE_TYPE, 'accountId'); + expect(storage.createIndex).toHaveBeenCalledWith(STORAGE_TYPE, 'webId'); }); - it('returns an empty list if there are no matching idenfitiers.', async(): Promise => { - storage.get.mockResolvedValueOnce(undefined); - await expect(store.get(webId)).resolves.toEqual([]); + it('can only initialize once.', async(): Promise => { + await expect(store.handle()).resolves.toBeUndefined(); + await expect(store.handle()).resolves.toBeUndefined(); + expect(storage.defineType).toHaveBeenCalledTimes(1); }); - it('can add an account to the linked list.', async(): Promise => { - await expect(store.add(webId, account)).resolves.toBe('http://example.com/.account/resource'); - expect(storage.set).toHaveBeenCalledTimes(1); - expect(storage.set).toHaveBeenLastCalledWith(webId, [ account.id ]); - expect(accountStore.update).toHaveBeenCalledTimes(1); - expect(accountStore.update).toHaveBeenLastCalledWith(account); - expect(account.webIds[webId]).toBe('http://example.com/.account/resource'); + it('throws an error if defining the type goes wrong.', async(): Promise => { + storage.defineType.mockRejectedValueOnce(new Error('bad data')); + await expect(store.handle()).rejects.toThrow(InternalServerError); }); - it('creates a new list if one did not exist yet.', async(): Promise => { - storage.get.mockResolvedValueOnce(undefined); - await expect(store.add(webId, account)).resolves.toBe('http://example.com/.account/resource'); - expect(storage.set).toHaveBeenCalledTimes(1); - expect(storage.set).toHaveBeenLastCalledWith(webId, [ account.id ]); - expect(accountStore.update).toHaveBeenCalledTimes(1); - expect(accountStore.update).toHaveBeenLastCalledWith(account); - expect(account.webIds[webId]).toBe('http://example.com/.account/resource'); + it('returns the matching information.', async(): Promise => { + await expect(store.get(id)).resolves.toEqual({ accountId, webId }); + expect(storage.get).toHaveBeenCalledTimes(1); + expect(storage.get).toHaveBeenLastCalledWith(STORAGE_TYPE, id); }); - it('can not create a link if the WebID is already linked.', async(): Promise => { - account.webIds[webId] = 'resource'; - await expect(store.add(webId, account)).rejects.toThrow(BadRequestHttpError); - expect(storage.set).toHaveBeenCalledTimes(0); - expect(accountStore.update).toHaveBeenCalledTimes(0); + it('can verify if a WebID is linked to an account.', async(): Promise => { + await expect(store.isLinked(webId, accountId)).resolves.toBe(true); + expect(storage.find).toHaveBeenCalledTimes(1); + expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { webId, accountId }); }); - it('does not update the account if something goes wrong.', async(): Promise => { - storage.set.mockRejectedValueOnce(new Error('bad data')); - await expect(store.add(webId, account)).rejects.toThrow('bad data'); - expect(storage.set).toHaveBeenCalledTimes(1); - expect(storage.set).toHaveBeenLastCalledWith(webId, [ account.id ]); - expect(accountStore.update).toHaveBeenCalledTimes(2); - expect(accountStore.update).toHaveBeenLastCalledWith(account); - expect(account.webIds).toEqual({}); + it('can find all WebIDs linked to an account.', async(): Promise => { + await expect(store.findLinks(accountId)).resolves.toEqual([{ id, webId }]); + expect(storage.find).toHaveBeenCalledTimes(1); + expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { accountId }); + }); + + it('can create a new WebID link.', async(): Promise => { + storage.find.mockResolvedValueOnce([]); + await expect(store.create(webId, accountId)).resolves.toBe(id); + expect(storage.find).toHaveBeenCalledTimes(1); + expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { webId, accountId }); + expect(storage.create).toHaveBeenCalledTimes(1); + expect(storage.create).toHaveBeenLastCalledWith(STORAGE_TYPE, { webId, accountId }); + }); + + it('can not create a link if the WebID is already linked to that account.', async(): Promise => { + await expect(store.create(webId, accountId)).rejects.toThrow(BadRequestHttpError); + expect(storage.find).toHaveBeenCalledTimes(1); + expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { webId, accountId }); + expect(storage.create).toHaveBeenCalledTimes(0); }); it('can delete a link.', async(): Promise => { - await expect(store.delete(webId, account)).resolves.toBeUndefined(); + await expect(store.delete(id)).resolves.toBeUndefined(); expect(storage.delete).toHaveBeenCalledTimes(1); - expect(storage.delete).toHaveBeenLastCalledWith(webId); - expect(accountStore.update).toHaveBeenCalledTimes(1); - expect(accountStore.update).toHaveBeenLastCalledWith(account); - expect(account.webIds).toEqual({}); - }); - - it('does not remove the entire list if there are still other entries.', async(): Promise => { - storage.get.mockResolvedValueOnce([ account.id, 'other-id' ]); - await expect(store.delete(webId, account)).resolves.toBeUndefined(); - expect(storage.set).toHaveBeenCalledTimes(1); - expect(storage.set).toHaveBeenLastCalledWith(webId, [ 'other-id' ]); - expect(accountStore.update).toHaveBeenCalledTimes(1); - expect(accountStore.update).toHaveBeenLastCalledWith(account); - expect(account.webIds).toEqual({}); - }); - - it('does not do anything if the the delete WebID target does not exist.', async(): Promise => { - storage.get.mockResolvedValueOnce(undefined); - await expect(store.delete('random-webId', account)).resolves.toBeUndefined(); - expect(storage.set).toHaveBeenCalledTimes(0); - expect(storage.delete).toHaveBeenCalledTimes(0); - expect(accountStore.update).toHaveBeenCalledTimes(0); - }); - - it('does not do anything if the the delete account target is not linked.', async(): Promise => { - await expect(store.delete(webId, { ...account, id: 'random-id' })).resolves.toBeUndefined(); - expect(storage.set).toHaveBeenCalledTimes(0); - expect(storage.delete).toHaveBeenCalledTimes(0); - expect(accountStore.update).toHaveBeenCalledTimes(0); + expect(storage.delete).toHaveBeenLastCalledWith(STORAGE_TYPE, id); }); }); diff --git a/test/util/AccountUtil.ts b/test/util/AccountUtil.ts index be01876d3..535453cd0 100644 --- a/test/util/AccountUtil.ts +++ b/test/util/AccountUtil.ts @@ -1,18 +1,4 @@ -import urljoin from 'url-join'; -import type { Account } from '../../src/identity/interaction/account/util/Account'; -import type { AccountStore } from '../../src/identity/interaction/account/util/AccountStore'; - -export function createAccount(id = 'id'): Account { - return { id, logins: {}, webIds: {}, pods: {}, clientCredentials: {}, settings: {}}; -} - -export function mockAccountStore(account?: Account): jest.Mocked { - return { - create: jest.fn(async(): Promise => createAccount()), - get: jest.fn().mockResolvedValue(account), - update: jest.fn(), - }; -} +import { joinUrl } from '../../src/util/PathUtil'; export type User = { email: string; @@ -30,7 +16,7 @@ export type User = { export async function register(baseUrl: string, user: User): Promise<{ pod: string; webId: string; authorization: string; controls: any }> { // Get controls - let res = await fetch(urljoin(baseUrl, '.account/')); + let res = await fetch(joinUrl(baseUrl, '.account/')); let { controls } = await res.json(); // Create account