mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Update IDP templates to work with new API format
This commit is contained in:
parent
bc0eeb1012
commit
a684b2ead7
@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright © 2019–2021 Inrupt Inc. and imec
|
||||
Copyright © 2019–2022 Inrupt Inc. and imec
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
@ -12,17 +12,22 @@
|
||||
"StaticAssetHandler:_assets_value": "@css:templates/images/favicon.ico"
|
||||
},
|
||||
{
|
||||
"StaticAssetHandler:_assets_key": "/.well_known/css/styles/",
|
||||
"StaticAssetHandler:_assets_key": "/.well-known/css/styles/",
|
||||
"StaticAssetHandler:_assets_value": "@css:templates/styles/"
|
||||
},
|
||||
{
|
||||
"StaticAssetHandler:_assets_key": "/.well_known/css/fonts/",
|
||||
"StaticAssetHandler:_assets_key": "/.well-known/css/fonts/",
|
||||
"StaticAssetHandler:_assets_value": "@css:templates/fonts/"
|
||||
},
|
||||
{
|
||||
"StaticAssetHandler:_assets_key": "/.well_known/css/images/",
|
||||
"StaticAssetHandler:_assets_key": "/.well-known/css/images/",
|
||||
"StaticAssetHandler:_assets_value": "@css:templates/images/"
|
||||
},
|
||||
{
|
||||
"StaticAssetHandler:_assets_key": "/.well-known/css/scripts/",
|
||||
"StaticAssetHandler:_assets_value": "@css:templates/scripts/"
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
]
|
||||
|
@ -3,6 +3,7 @@
|
||||
"import": [
|
||||
"files-scs:config/identity/handler/interaction/routes/existing-login.json",
|
||||
"files-scs:config/identity/handler/interaction/routes/forgot-password.json",
|
||||
"files-scs:config/identity/handler/interaction/routes/index.json",
|
||||
"files-scs:config/identity/handler/interaction/routes/login.json",
|
||||
"files-scs:config/identity/handler/interaction/routes/prompt.json",
|
||||
"files-scs:config/identity/handler/interaction/routes/reset-password.json",
|
||||
@ -21,7 +22,18 @@
|
||||
{
|
||||
"comment": "Adds controls and API version to JSON responses.",
|
||||
"@id": "urn:solid-server:auth:password:ControlHandler",
|
||||
"ControlHandler:_source" : {
|
||||
"ControlHandler:_source" : { "@id": "urn:solid-server:auth:password:LocationInteractionHandler" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "Converts redirect errors to location JSON responses.",
|
||||
"@id": "urn:solid-server:auth:password:LocationInteractionHandler",
|
||||
"@type": "LocationInteractionHandler",
|
||||
"LocationInteractionHandler:_source" : { "@id": "urn:solid-server:auth:password:RouteInteractionHandler" }
|
||||
},
|
||||
{
|
||||
"comment": "Handles every interaction based on their route.",
|
||||
"@id": "urn:solid-server:auth:password:RouteInteractionHandler",
|
||||
"@type": "WaterfallHandler",
|
||||
"handlers": [
|
||||
@ -32,6 +44,7 @@
|
||||
],
|
||||
"@type": "UnsupportedAsyncHandler"
|
||||
},
|
||||
{ "@id": "urn:solid-server:auth:password:IndexRoute" },
|
||||
{ "@id": "urn:solid-server:auth:password:PromptRoute" },
|
||||
{ "@id": "urn:solid-server:auth:password:LoginRoute" },
|
||||
{ "@id": "urn:solid-server:auth:password:ExistingLoginRoute" },
|
||||
@ -39,8 +52,5 @@
|
||||
{ "@id": "urn:solid-server:auth:password:ResetPasswordRoute" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -5,8 +5,8 @@
|
||||
"comment": "Handles the interaction that occurs when a logged in user wants to authenticate with a new app.",
|
||||
"@id": "urn:solid-server:auth:password:ExistingLoginRoute",
|
||||
"@type": "RelativeInteractionRoute",
|
||||
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||
"relativePath": "/idp/consent/",
|
||||
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
|
||||
"relativePath": "/consent/",
|
||||
"source": {
|
||||
"@type": "ExistingLoginHandler",
|
||||
"interactionCompleter": { "@type": "BaseInteractionCompleter" }
|
||||
|
@ -5,8 +5,8 @@
|
||||
"comment": "Handles the forgot password interaction",
|
||||
"@id": "urn:solid-server:auth:password:ForgotPasswordRoute",
|
||||
"@type": "RelativeInteractionRoute",
|
||||
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||
"relativePath": "/idp/forgotpassword/",
|
||||
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
|
||||
"relativePath": "/forgotpassword/",
|
||||
"source": {
|
||||
"@type": "ForgotPasswordHandler",
|
||||
"args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
|
||||
|
16
config/identity/handler/interaction/routes/index.json
Normal file
16
config/identity/handler/interaction/routes/index.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
|
||||
"@graph": [
|
||||
{
|
||||
"comment": "Root API entry. Returns an empty body so we can add controls pointing to other interaction routes.",
|
||||
"@id": "urn:solid-server:auth:password:IndexRoute",
|
||||
"@type": "RelativeInteractionRoute",
|
||||
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||
"relativePath": "/idp/",
|
||||
"source": {
|
||||
"@type": "FixedInteractionHandler",
|
||||
"response": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
@ -5,8 +5,8 @@
|
||||
"comment": "Handles the login interaction",
|
||||
"@id": "urn:solid-server:auth:password:LoginRoute",
|
||||
"@type": "RelativeInteractionRoute",
|
||||
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||
"relativePath": "/idp/login/",
|
||||
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
|
||||
"relativePath": "/login/",
|
||||
"source": {
|
||||
"@type": "LoginHandler",
|
||||
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
|
||||
|
@ -5,10 +5,11 @@
|
||||
"comment": "Handles OIDC redirects containing a prompt, such as login or consent.",
|
||||
"@id": "urn:solid-server:auth:password:PromptRoute",
|
||||
"@type": "RelativeInteractionRoute",
|
||||
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||
"relativePath": "/idp/",
|
||||
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
|
||||
"relativePath": "/prompt/",
|
||||
"source": {
|
||||
"@type": "PromptHandler",
|
||||
"@id": "urn:solid-server:auth:password:PromptHandler",
|
||||
"promptRoutes": [
|
||||
{
|
||||
"PromptHandler:_promptRoutes_key": "login",
|
||||
|
@ -5,8 +5,8 @@
|
||||
"comment": "Handles the reset password interaction",
|
||||
"@id": "urn:solid-server:auth:password:ResetPasswordRoute",
|
||||
"@type": "RelativeInteractionRoute",
|
||||
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||
"relativePath": "/idp/resetpassword/",
|
||||
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
|
||||
"relativePath": "/resetpassword/",
|
||||
"source": {
|
||||
"@type": "ResetPasswordHandler",
|
||||
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }
|
||||
|
@ -5,6 +5,14 @@
|
||||
"@id": "urn:solid-server:auth:password:ControlHandler",
|
||||
"@type": "ControlHandler",
|
||||
"controls": [
|
||||
{
|
||||
"ControlHandler:_controls_key": "index",
|
||||
"ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:IndexRoute" }
|
||||
},
|
||||
{
|
||||
"ControlHandler:_controls_key": "prompt",
|
||||
"ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:PromptRoute" }
|
||||
},
|
||||
{
|
||||
"ControlHandler:_controls_key": "login",
|
||||
"ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:LoginRoute" }
|
||||
|
@ -4,6 +4,7 @@
|
||||
{
|
||||
"@id": "urn:solid-server:auth:password:HtmlViewHandler",
|
||||
"@type": "HtmlViewHandler",
|
||||
"index": { "@id": "urn:solid-server:auth:password:IndexRoute" },
|
||||
"templateEngine": {
|
||||
"comment": "Renders the specific page and embeds it into the main HTML body.",
|
||||
"@type": "ChainedTemplateEngine",
|
||||
|
@ -11,7 +11,7 @@
|
||||
"args_adapterFactory": { "@id": "urn:solid-server:default:IdpAdapterFactory" },
|
||||
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||
"args_oidcPath": "/.oidc",
|
||||
"args_idpPath": "/idp",
|
||||
"args_interactionHandler": { "@id": "urn:solid-server:auth:password:PromptHandler" },
|
||||
"args_storage": { "@id": "urn:solid-server:default:IdpKeyStorage" },
|
||||
"args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" },
|
||||
"args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" },
|
||||
|
@ -32,6 +32,14 @@
|
||||
{
|
||||
"HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/register.html.ejs",
|
||||
"HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:RegistrationRoute" }
|
||||
},
|
||||
{
|
||||
"HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/reset-password-response.html.ejs",
|
||||
"HtmlViewHandler:_templates_value": {
|
||||
"@type": "RelativeInteractionRoute",
|
||||
"base": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" },
|
||||
"relativePath": "/response/"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -5,8 +5,8 @@
|
||||
"comment": "Handles the register interaction",
|
||||
"@id": "urn:solid-server:auth:password:RegistrationRoute",
|
||||
"@type": "RelativeInteractionRoute",
|
||||
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||
"relativePath": "/idp/register/",
|
||||
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
|
||||
"relativePath": "/register/",
|
||||
"source": {
|
||||
"@type": "RegistrationHandler",
|
||||
"registrationManager": {
|
||||
|
26
src/identity/interaction/FixedInteractionHandler.ts
Normal file
26
src/identity/interaction/FixedInteractionHandler.ts
Normal file
@ -0,0 +1,26 @@
|
||||
/* eslint-disable tsdoc/syntax */
|
||||
// tsdoc/syntax cannot handle `@range`
|
||||
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
|
||||
import type { Representation } from '../../http/representation/Representation';
|
||||
import { APPLICATION_JSON } from '../../util/ContentTypes';
|
||||
import type { InteractionHandlerInput } from './InteractionHandler';
|
||||
import { InteractionHandler } from './InteractionHandler';
|
||||
|
||||
/**
|
||||
* An {@link InteractionHandler} that always returns the same JSON response on all requests.
|
||||
*/
|
||||
export class FixedInteractionHandler extends InteractionHandler {
|
||||
private readonly response: string;
|
||||
|
||||
/**
|
||||
* @param response - @range {json}
|
||||
*/
|
||||
public constructor(response: unknown) {
|
||||
super();
|
||||
this.response = JSON.stringify(response);
|
||||
}
|
||||
|
||||
public async handle({ operation }: InteractionHandlerInput): Promise<Representation> {
|
||||
return new BasicRepresentation(this.response, operation.target, APPLICATION_JSON);
|
||||
}
|
||||
}
|
@ -18,13 +18,19 @@ import type { InteractionRoute } from './routing/InteractionRoute';
|
||||
* Will only handle GET operations for which there is a matching template if HTML is more preferred than JSON.
|
||||
* Reason for doing it like this instead of a standard content negotiation flow
|
||||
* is because we only want to return the HTML pages on GET requests. *
|
||||
*
|
||||
* Templates will receive the parameter `idpIndex` in their context pointing to the root index URL of the IDP API
|
||||
* and an `authenticating` parameter indicating if this is an active OIDC interaction.
|
||||
*/
|
||||
export class HtmlViewHandler extends InteractionHandler {
|
||||
private readonly idpIndex: string;
|
||||
private readonly templateEngine: TemplateEngine;
|
||||
private readonly templates: Record<string, string>;
|
||||
|
||||
public constructor(templateEngine: TemplateEngine, templates: Record<string, InteractionRoute>) {
|
||||
public constructor(index: InteractionRoute, templateEngine: TemplateEngine,
|
||||
templates: Record<string, InteractionRoute>) {
|
||||
super();
|
||||
this.idpIndex = index.getPath();
|
||||
this.templateEngine = templateEngine;
|
||||
this.templates = Object.fromEntries(
|
||||
Object.entries(templates).map(([ template, route ]): [ string, string ] => [ route.getPath(), template ]),
|
||||
@ -46,9 +52,10 @@ export class HtmlViewHandler extends InteractionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
public async handle({ operation }: InteractionHandlerInput): Promise<Representation> {
|
||||
public async handle({ operation, oidcInteraction }: InteractionHandlerInput): Promise<Representation> {
|
||||
const template = this.templates[operation.target.path];
|
||||
const result = await this.templateEngine.render({}, { templateFile: template });
|
||||
const contents = { idpIndex: this.idpIndex, authenticating: Boolean(oidcInteraction) };
|
||||
const result = await this.templateEngine.render(contents, { templateFile: template });
|
||||
return new BasicRepresentation(result, operation.target, TEXT_HTML);
|
||||
}
|
||||
}
|
||||
|
40
src/identity/interaction/LocationInteractionHandler.ts
Normal file
40
src/identity/interaction/LocationInteractionHandler.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
|
||||
import type { Representation } from '../../http/representation/Representation';
|
||||
import { APPLICATION_JSON } from '../../util/ContentTypes';
|
||||
import { RedirectHttpError } from '../../util/errors/RedirectHttpError';
|
||||
import type { InteractionHandlerInput } from './InteractionHandler';
|
||||
import { InteractionHandler } from './InteractionHandler';
|
||||
|
||||
/**
|
||||
* Catches redirect errors from the source and returns a JSON body containing a `location` field instead.
|
||||
* This allows the API to be used more easily from the browser.
|
||||
*
|
||||
* The issue is that if the API actually did a redirect,
|
||||
* this would make it unusable when using it on HTML pages that need to render errors in case the fetch fails,
|
||||
* but want to redirect the page in case it succeeds.
|
||||
* See full overview at https://github.com/solid/community-server/pull/1088.
|
||||
*/
|
||||
export class LocationInteractionHandler extends InteractionHandler {
|
||||
private readonly source: InteractionHandler;
|
||||
|
||||
public constructor(source: InteractionHandler) {
|
||||
super();
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
public async canHandle(input: InteractionHandlerInput): Promise<void> {
|
||||
await this.source.canHandle(input);
|
||||
}
|
||||
|
||||
public async handle(input: InteractionHandlerInput): Promise<Representation> {
|
||||
try {
|
||||
return await this.source.handle(input);
|
||||
} catch (error: unknown) {
|
||||
if (RedirectHttpError.isInstance(error)) {
|
||||
const body = JSON.stringify({ location: error.location });
|
||||
return new BasicRepresentation(body, input.operation.target, APPLICATION_JSON);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
@ -160,8 +160,10 @@ export * from './identity/interaction/BaseInteractionHandler';
|
||||
export * from './identity/interaction/CompletingInteractionHandler';
|
||||
export * from './identity/interaction/ExistingLoginHandler';
|
||||
export * from './identity/interaction/ControlHandler';
|
||||
export * from './identity/interaction/FixedInteractionHandler';
|
||||
export * from './identity/interaction/HtmlViewHandler';
|
||||
export * from './identity/interaction/InteractionHandler';
|
||||
export * from './identity/interaction/LocationInteractionHandler';
|
||||
export * from './identity/interaction/PromptHandler';
|
||||
|
||||
// Identity/Ownership
|
||||
|
@ -1,9 +1,7 @@
|
||||
<h1>Authorize</h1>
|
||||
<p>You are authorizing an application to access your Pod.</p>
|
||||
<form method="post">
|
||||
<% if (locals.message) { %>
|
||||
<p class="error"><%= message %></p>
|
||||
<% } %>
|
||||
<form method="post" id="mainForm">
|
||||
<p class="error" id="error"></p>
|
||||
|
||||
<fieldset>
|
||||
<ol>
|
||||
@ -15,3 +13,7 @@
|
||||
|
||||
<p class="actions"><button autofocus type="submit" name="submit">Continue</button></p>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
addPostListener('mainForm', 'error', '', () => { throw new Error('Expected a location field in the response.') });
|
||||
</script>
|
||||
|
@ -1,13 +0,0 @@
|
||||
<h1>Email sent</h1>
|
||||
<form method="post">
|
||||
<p>If your account exists, an email has been sent with a link to reset your password.</p>
|
||||
<p>If you do not receive your email in a couple of minutes, check your spam folder or click the link below to send another email.</p>
|
||||
|
||||
<input type="hidden" name="email" value="<%= email %>" />
|
||||
|
||||
<p class="actions"><a href="<%= controls.login %>">Back to Log In</a></p>
|
||||
|
||||
<p class="actions">
|
||||
<button type="submit" name="submit" class="link">Send Another Email</button>
|
||||
</p>
|
||||
</form>
|
@ -1,19 +1,45 @@
|
||||
<h1>Forgot password</h1>
|
||||
<form method="post">
|
||||
<% if (locals.message) { %>
|
||||
<p class="error"><%= message %></p>
|
||||
<% } %>
|
||||
<div id="input-partial">
|
||||
<h1>Forgot password</h1>
|
||||
<form method="post" id="mainForm">
|
||||
<p class="error" id="error"></p>
|
||||
|
||||
<fieldset>
|
||||
<ol>
|
||||
<li>
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" name="email" autofocus>
|
||||
<label for="input-email">Email</label>
|
||||
<input id="input-email" type="email" name="email" autofocus>
|
||||
</li>
|
||||
</ol>
|
||||
</fieldset>
|
||||
|
||||
<p class="actions"><button type="submit" name="submit">Send recovery email</button></p>
|
||||
|
||||
<p class="actions"><a href="<%= controls.login %>" class="link">Log in</a></p>
|
||||
</form>
|
||||
<p class="actions"><a id="input-login-link" href="" class="link">Log in</a></p>
|
||||
</form>
|
||||
</div>
|
||||
<div id="response-partial">
|
||||
<h1>Email sent</h1>
|
||||
<p>If your account exists, an email has been sent with a link to reset your password.</p>
|
||||
<p>If you do not receive your email in a couple of minutes, check your spam folder or try sending another email.</p>
|
||||
|
||||
<ul class="actions">
|
||||
<li><a id="response-login-link" href="" class="link">Back to Log In</a></li>
|
||||
<li><a id="response-forgot-link" href="" class="link">Back to Forgot Password</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
addControlLinks('<%= idpIndex %>', {
|
||||
'input-login-link': 'login',
|
||||
'response-login-link': 'login',
|
||||
'response-forgot-link': 'forgotPassword'
|
||||
});
|
||||
|
||||
setVisibility('response-partial', false);
|
||||
function updateResponse() {
|
||||
// Swap visibility
|
||||
setVisibility('input-partial', false);
|
||||
setVisibility('response-partial', true);
|
||||
}
|
||||
addPostListener('mainForm', 'error', '', updateResponse);
|
||||
</script>
|
||||
|
@ -1,17 +1,14 @@
|
||||
<h1>Log in</h1>
|
||||
<form method="post">
|
||||
<% prefilled = locals.prefilled || {}; %>
|
||||
|
||||
<% if (locals.message) { %>
|
||||
<p class="error"><%= message %></p>
|
||||
<% } %>
|
||||
<div id="authenticating">
|
||||
<h1>Log in</h1>
|
||||
<form method="post" id="mainForm">
|
||||
<p class="error" id="error"></p>
|
||||
|
||||
<fieldset>
|
||||
<legend>Your account</legend>
|
||||
<ol>
|
||||
<li>
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" name="email" autofocus value="<%= prefilled.email || '' %>">
|
||||
<input id="email" type="email" name="email" autofocus>
|
||||
</li>
|
||||
<li>
|
||||
<label for="password">Password</label>
|
||||
@ -26,7 +23,34 @@
|
||||
<p class="actions"><button type="submit" name="submit">Log in</button></p>
|
||||
|
||||
<ul class="actions">
|
||||
<li><a href="<%= controls.register %>" class="link">Sign up</a></li>
|
||||
<li><a href="<%= controls.forgotPassword %>" class="link">Forgot password</a></li>
|
||||
<li><a id="register-link" href="" class="link">Sign up</a></li>
|
||||
<li><a id="forgot-link" href="" class="link">Forgot password</a></li>
|
||||
</ul>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
<div id="not-authenticating">
|
||||
<h1>Please log in through an app</h1>
|
||||
<p><strong>To log in and access documents, you need to use a Solid app.</strong></p>
|
||||
<p>This server provides secure storage, but it is not a client app.</p>
|
||||
<p>
|
||||
Choose one of the
|
||||
<a href="https://solidproject.org/apps" class="link">Solid apps</a>
|
||||
to log in and browse Pods.
|
||||
</p>
|
||||
<p>
|
||||
If you're developing an app yourself,
|
||||
use a library such as
|
||||
<a href="https://github.com/inrupt/solid-client-authn-js" class="link"><code>solid-client-authn-js</code></a>
|
||||
to initiate an OIDC authentication flow.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
setVisibility('authenticating', <%= Boolean(authenticating) %>);
|
||||
setVisibility('not-authenticating', <%= !Boolean(authenticating) %>);
|
||||
|
||||
addPostListener('mainForm', 'error', '', () => { throw new Error('Expected a location field in the response.') });
|
||||
|
||||
addControlLinks('<%= idpIndex %>', { 'register-link': 'register', 'forgot-link': 'forgotPassword'});
|
||||
</script>
|
||||
|
@ -165,32 +165,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Checks whether the given element is visible
|
||||
function isVisible(element) {
|
||||
return !(elements[element] ?? element).classList.contains('hidden');
|
||||
}
|
||||
|
||||
// Sets the visibility of the given element
|
||||
function setVisibility(element, visible) {
|
||||
// Show or hide the element
|
||||
element = elements[element] ?? element;
|
||||
element.classList[visible ? 'remove' : 'add']('hidden');
|
||||
|
||||
// Disable children of hidden elements,
|
||||
// such that the browser does not expect input for them
|
||||
for (const child of getDescendants(element)) {
|
||||
if ('disabled' in child)
|
||||
child.disabled = !visible;
|
||||
}
|
||||
}
|
||||
|
||||
// Obtains all children, grandchildren, etc. of the given element
|
||||
function getDescendants(element) {
|
||||
return [...element.querySelectorAll("*")];
|
||||
}
|
||||
|
||||
// Prepare the form when the DOM is ready
|
||||
window.addEventListener('DOMContentLoaded', (event) => {
|
||||
addEventListener('DOMContentLoaded', (event) => {
|
||||
synchronizeInputFields();
|
||||
elements.mainForm.classList.add('loaded');
|
||||
});
|
||||
|
@ -1,38 +1,54 @@
|
||||
<% if (createPod) { %>
|
||||
<div id="response-createPod">
|
||||
<h2>Your new Pod</h2>
|
||||
<p>
|
||||
Your new Pod is located at <a href="<%= podBaseUrl %>" class="link"><%= podBaseUrl %></a>.
|
||||
Your new Pod is located at <a id="response-podBaseUrl" href="" class="link"></a>.
|
||||
<br>
|
||||
You can store your documents and data there.
|
||||
</p>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<% if (createWebId) { %>
|
||||
<div id="response-createWebId">
|
||||
<h2>Your new WebID</h2>
|
||||
<p>
|
||||
Your new WebID is <a href="<%= webId %>" class="link"><%= webId %></a>.
|
||||
Your new WebID is <a id="response-createdWebId" href="" class="link"></a>.
|
||||
<br>
|
||||
You can use this identifier to interact with Solid pods and apps.
|
||||
</p>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<% if (register) { %>
|
||||
<div id="response-register">
|
||||
<h2>Your new account</h2>
|
||||
<p>
|
||||
Via your email address <em><%= email %></em>,
|
||||
<% if (authenticating) { %>
|
||||
you can now <a href="<%= controls.login %>">log in</a>
|
||||
<% } else { %>
|
||||
Via your email address <em id="response-email"></em>,
|
||||
this server lets you log in to Solid apps
|
||||
<% } %>
|
||||
with your WebID <a href="<%= webId %>" class="link"><%= webId %></a>
|
||||
with your WebID <a id="response-registeredWebId" href="" class="link"></a>
|
||||
</p>
|
||||
<% if (!createWebId) { %>
|
||||
<div id="response-registerWebId">
|
||||
<p>
|
||||
You will need to add the triple
|
||||
<code><%= `<${webId}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${oidcIssuer}>.`%></code>
|
||||
to your existing WebID document <em><%= webId %></em>
|
||||
<code id="response-oidcIssuerTriple"></code>
|
||||
to your existing WebID document <em id="response-existingWebId"></em>
|
||||
to indicate that you trust this server as a login provider.
|
||||
</p>
|
||||
<% } %>
|
||||
<% } %>
|
||||
</div>
|
||||
<p>
|
||||
You can now <a id="response-login-link" href="">log in</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updateResponseFields(json) {
|
||||
updateElement('response-podBaseUrl', json.podBaseUrl, { innerText: true, href: true });
|
||||
updateElement('response-createdWebId', json.webId, { innerText: true, href: true });
|
||||
updateElement('response-registeredWebId', json.webId, { innerText: true, href: true });
|
||||
const triple = `<${json.webId}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${json.oidcIssuer}>.`;
|
||||
updateElement('response-oidcIssuerTriple', triple, { innerText: true });
|
||||
updateElement('response-existingWebId', json.webId, { innerText: true });
|
||||
updateElement('response-email', json.email, { innerText: true });
|
||||
setVisibility('response-createPod', json.createPod);
|
||||
setVisibility('response-createWebId', json.createWebId);
|
||||
setVisibility('response-registerWebId', !json.createWebId);
|
||||
setVisibility('response-register', json.register);
|
||||
updateElement('response-login-link', json.controls.login, { href: true });
|
||||
}
|
||||
</script>
|
||||
|
@ -1,7 +0,0 @@
|
||||
<h1>You've been signed up</h1>
|
||||
<p>
|
||||
<strong>Welcome to Solid.</strong>
|
||||
We wish you an exciting experience!
|
||||
</p>
|
||||
|
||||
<%- include('./register-response-partial.html.ejs') %>
|
@ -1,11 +1,31 @@
|
||||
<h1>Sign up</h1>
|
||||
<form method="post" id="mainForm">
|
||||
|
||||
<% if (locals.message) { %>
|
||||
<p class="error">Error: <%= message %></p>
|
||||
<% } %>
|
||||
<div id="input-partial">
|
||||
<h1>Sign up</h1>
|
||||
<form method="post" id="mainForm">
|
||||
<p class="error" id="error"></p>
|
||||
|
||||
<%- include('./register-partial.html.ejs', { allowRoot: false }) %>
|
||||
|
||||
<p class="actions"><button type="submit" name="submit">Sign up</button></p>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
<div id="response-partial">
|
||||
<h1>You've been signed up</h1>
|
||||
<p>
|
||||
<strong>Welcome to Solid.</strong>
|
||||
We wish you an exciting experience!
|
||||
</p>
|
||||
|
||||
<%- include('./register-response-partial.html.ejs') %>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
setVisibility('response-partial', false);
|
||||
function updateResponse(json) {
|
||||
// Swap visibility
|
||||
setVisibility('input-partial', false);
|
||||
setVisibility('response-partial', true);
|
||||
|
||||
updateResponseFields(json);
|
||||
}
|
||||
addPostListener('mainForm', 'error', '', updateResponse);
|
||||
</script>
|
||||
|
@ -1,2 +0,0 @@
|
||||
<h1>Password reset</h1>
|
||||
<p>Your password was successfully reset.</p>
|
@ -1,8 +1,7 @@
|
||||
<h1>Reset password</h1>
|
||||
<form method="post">
|
||||
<% if (locals.message) { %>
|
||||
<p class="error"><%= message %></p>
|
||||
<% } %>
|
||||
<div id="input-partial">
|
||||
<h1>Reset password</h1>
|
||||
<form method="post" id="mainForm">
|
||||
<p class="error" id="error"></p>
|
||||
|
||||
<fieldset>
|
||||
<ol>
|
||||
@ -19,10 +18,23 @@
|
||||
</fieldset>
|
||||
|
||||
<p class="actions"><button type="submit" name="submit">Reset password</button></p>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
<div id="response-partial">
|
||||
<h1>Password reset</h1>
|
||||
<p>Your password was successfully reset.</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const hidden = document.getElementById('recordId');
|
||||
const recordId = new URLSearchParams(window.location.search).get('rid');
|
||||
const recordId = new URLSearchParams(location.search).get('rid');
|
||||
hidden.value = recordId;
|
||||
|
||||
setVisibility('response-partial', false);
|
||||
function updateResponse() {
|
||||
// Swap visibility
|
||||
setVisibility('input-partial', false);
|
||||
setVisibility('response-partial', true);
|
||||
}
|
||||
addPostListener('mainForm', 'error', '', updateResponse);
|
||||
</script>
|
||||
|
@ -4,11 +4,12 @@
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title><%= extractTitle(htmlBody) %></title>
|
||||
<link rel="stylesheet" href="/.well_known/css/styles/main.css" type="text/css">
|
||||
<link rel="stylesheet" href="/.well-known/css/styles/main.css" type="text/css">
|
||||
<script type="text/javascript" src="/.well-known/css/scripts/util.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a href="/"><img src="/.well_known/css/images/solid.svg" alt="[Solid logo]" /></a>
|
||||
<a href="/"><img src="/.well-known/css/images/solid.svg" alt="[Solid logo]" /></a>
|
||||
<h1>Community Solid Server</h1>
|
||||
</header>
|
||||
<main>
|
||||
@ -16,7 +17,7 @@
|
||||
</main>
|
||||
<footer>
|
||||
<p>
|
||||
©2019–2021 <a href="https://inrupt.com/">Inrupt Inc.</a>
|
||||
©2019–2022 <a href="https://inrupt.com/">Inrupt Inc.</a>
|
||||
and <a href="https://www.imec-int.com/">imec</a>
|
||||
</p>
|
||||
</footer>
|
||||
|
@ -4,11 +4,11 @@
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Community Solid Server</title>
|
||||
<link rel="stylesheet" href="/.well_known/css/styles/main.css" type="text/css">
|
||||
<link rel="stylesheet" href="/.well-known/css/styles/main.css" type="text/css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a href="/"><img src="/.well_known/css/images/solid.svg" alt="[Solid logo]" /></a>
|
||||
<a href="/"><img src="/.well-known/css/images/solid.svg" alt="[Solid logo]" /></a>
|
||||
<h1>Community Solid Server</h1>
|
||||
</header>
|
||||
<main>
|
||||
@ -58,7 +58,7 @@
|
||||
</main>
|
||||
<footer>
|
||||
<p>
|
||||
©2019–2021 <a href="https://inrupt.com/">Inrupt Inc.</a>
|
||||
©2019–2022 <a href="https://inrupt.com/">Inrupt Inc.</a>
|
||||
and <a href="https://www.imec-int.com/">imec</a>
|
||||
</p>
|
||||
</footer>
|
||||
|
142
templates/scripts/util.js
Normal file
142
templates/scripts/util.js
Normal file
@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Acquires all data from the given form and POSTs it as JSON to the target URL.
|
||||
* In case of failure this function will throw an error.
|
||||
* In case of success a parsed JSON body of the response will be returned,
|
||||
* unless the body contains a `location` field,
|
||||
* in that case the page will be redirected to that location.
|
||||
*
|
||||
* @param formId - ID of the form.
|
||||
* @param target - Target URL to POST to. Defaults to the current URL.
|
||||
* @returns {Promise<unknown>} - The response JSON.
|
||||
*/
|
||||
async function postJsonForm(formId, target = '') {
|
||||
const form = document.getElementById(formId);
|
||||
const formData = new FormData(form);
|
||||
const res = await fetch(target, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'accept': 'application/json', 'content-type': 'application/json' },
|
||||
body: JSON.stringify(Object.fromEntries(formData)),
|
||||
});
|
||||
if (res.status >= 400) {
|
||||
const error = await res.json();
|
||||
throw new Error(`${error.statusCode} - ${error.name}: ${error.message}`)
|
||||
} else if (res.status === 200 || res.status === 201) {
|
||||
const body = await res.json();
|
||||
if (body.location) {
|
||||
location.href = body.location;
|
||||
} else {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects the page to the given target with the key/value pairs of the JSON body as query parameters.
|
||||
* Controls will be deleted from the JSON to prevent very large URLs.
|
||||
* `false` values will be deleted to prevent incorrect serializations to "false".
|
||||
* @param json - JSON to convert.
|
||||
* @param target - URL to redirect to.
|
||||
*/
|
||||
function redirectJsonResponse(json, target) {
|
||||
// These would cause the URL to get very large, can be acquired later if needed
|
||||
delete json.controls;
|
||||
|
||||
// Remove false parameters since these would be converted to "false" strings
|
||||
for (const [key, val] of Object.entries(json)) {
|
||||
if (typeof val === 'boolean' && !val) {
|
||||
delete json[key];
|
||||
}
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(Object.entries(json));
|
||||
location.href = `${target}?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a listener to the given form to catch the form submission and do an API call instead.
|
||||
* In case of an error, the inner text of the given error block will be updated with the message.
|
||||
* In case of success the callback function will be called.
|
||||
*
|
||||
* @param formId - ID of the form.
|
||||
* @param errorId - ID of the error block.
|
||||
* @param apiTarget - Target URL to send the POST request to. Defaults to the current URL.
|
||||
* @param callback - Callback function that will be called with the response JSON.
|
||||
*/
|
||||
async function addPostListener(formId, errorId, apiTarget, callback) {
|
||||
const form = document.getElementById(formId);
|
||||
const errorBlock = document.getElementById(errorId);
|
||||
|
||||
form.addEventListener('submit', async(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
const json = await postJsonForm(formId, apiTarget);
|
||||
callback(json);
|
||||
} catch (error) {
|
||||
errorBlock.innerText = error.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates links on a page based on the controls received from the API.
|
||||
* @param url - API URL that will return the controls
|
||||
* @param controlMap - Key/value map with keys being element IDs and values being the control field names.
|
||||
*/
|
||||
async function addControlLinks(url, controlMap) {
|
||||
const json = await fetchJson(url);
|
||||
for (let [ id, control ] of Object.entries(controlMap)) {
|
||||
updateElement(id, json.controls[control], { href: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows or hides the given element.
|
||||
* @param id - ID of the element.
|
||||
* @param visible - If it should be visible.
|
||||
*/
|
||||
function setVisibility(id, visible) {
|
||||
const element = document.getElementById(id);
|
||||
element.classList[visible ? 'remove' : 'add']('hidden');
|
||||
// Disable children of hidden elements,
|
||||
// such that the browser does not expect input for them
|
||||
for (const child of getDescendants(element)) {
|
||||
if ('disabled' in child)
|
||||
child.disabled = !visible;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtains all children, grandchildren, etc. of the given element.
|
||||
* @param element - Element to get all descendants from.
|
||||
*/
|
||||
function getDescendants(element) {
|
||||
return [...element.querySelectorAll("*")];
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the inner text and href field of an element.
|
||||
* @param id - ID of the element.
|
||||
* @param text - Text to put in the field(s).
|
||||
* @param options - Indicates which fields should be updated.
|
||||
* Keys should be `innerText` and/or `href`, values should be booleans.
|
||||
*/
|
||||
function updateElement(id, text, options) {
|
||||
const element = document.getElementById(id);
|
||||
if (options.innerText) {
|
||||
element.innerText = text;
|
||||
}
|
||||
if (options.href) {
|
||||
element.href = text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches JSON from the url and converts it to an object.
|
||||
* @param url - URL to fetch JSON from.
|
||||
*/
|
||||
async function fetchJson(url) {
|
||||
const res = await fetch(url, { headers: { accept: 'application/json' } });
|
||||
return res.json();
|
||||
}
|
@ -17,5 +17,38 @@
|
||||
<% } %>
|
||||
|
||||
<% if (registration) { %>
|
||||
<%- include('../identity/email-password/register-response-partial.html.ejs', { authenticating: false }) %>
|
||||
<% if (createPod) { %>
|
||||
<h2>Your new Pod</h2>
|
||||
<p>
|
||||
Your new Pod is located at <a href="<%= podBaseUrl %>" class="link"><%= podBaseUrl %></a>.
|
||||
<br>
|
||||
You can store your documents and data there.
|
||||
</p>
|
||||
<% } %>
|
||||
|
||||
<% if (createWebId) { %>
|
||||
<h2>Your new WebID</h2>
|
||||
<p>
|
||||
Your new WebID is <a href="<%= webId %>" class="link"><%= webId %></a>.
|
||||
<br>
|
||||
You can use this identifier to interact with Solid pods and apps.
|
||||
</p>
|
||||
<% } %>
|
||||
|
||||
<% if (register) { %>
|
||||
<h2>Your new account</h2>
|
||||
<p>
|
||||
Via your email address <em><%= email %></em>,
|
||||
this server lets you log in to Solid apps
|
||||
with your WebID <a href="<%= webId %>" class="link"><%= webId %></a>
|
||||
</p>
|
||||
<% if (!createWebId) { %>
|
||||
<p>
|
||||
You will need to add the triple
|
||||
<code><%= `<${webId}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${oidcIssuer}>.`%></code>
|
||||
to your existing WebID document <em><%= webId %></em>
|
||||
to indicate that you trust this server as a login provider.
|
||||
</p>
|
||||
<% } %>
|
||||
<% } %>
|
||||
<% } %>
|
||||
|
@ -233,11 +233,18 @@ form ul.actions > li {
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
/* Directly hide hidden elements. */
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide form elements with a sliding animation so users can track more easily what is happening. */
|
||||
form.loaded * {
|
||||
max-height: 1000px;
|
||||
transition: max-height .2s;
|
||||
}
|
||||
form .hidden {
|
||||
display: block;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
@ -0,0 +1,15 @@
|
||||
import type { Operation } from '../../../../src/http/Operation';
|
||||
import { FixedInteractionHandler } from '../../../../src/identity/interaction/FixedInteractionHandler';
|
||||
import { readJsonStream } from '../../../../src/util/StreamUtil';
|
||||
|
||||
describe('A FixedInteractionHandler', (): void => {
|
||||
const json = { data: 'data' };
|
||||
const operation: Operation = { target: { path: 'http://example.com/test/' }} as any;
|
||||
const handler = new FixedInteractionHandler(json);
|
||||
|
||||
it('returns the given JSON as response.', async(): Promise<void> => {
|
||||
const response = await handler.handle({ operation });
|
||||
await expect(readJsonStream(response.data)).resolves.toEqual(json);
|
||||
expect(response.metadata.contentType).toBe('application/json');
|
||||
});
|
||||
});
|
@ -10,12 +10,18 @@ import { readableToString } from '../../../../src/util/StreamUtil';
|
||||
import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine';
|
||||
|
||||
describe('An HtmlViewHandler', (): void => {
|
||||
const idpIndex = 'http://example.com/idp/';
|
||||
let index: InteractionRoute;
|
||||
let operation: Operation;
|
||||
let templates: Record<string, jest.Mocked<InteractionRoute>>;
|
||||
let templateEngine: TemplateEngine;
|
||||
let handler: HtmlViewHandler;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
index = {
|
||||
getPath: jest.fn().mockReturnValue(idpIndex),
|
||||
} as any;
|
||||
|
||||
operation = {
|
||||
method: 'GET',
|
||||
target: { path: 'http://example.com/idp/login/' },
|
||||
@ -32,7 +38,7 @@ describe('An HtmlViewHandler', (): void => {
|
||||
render: jest.fn().mockReturnValue(Promise.resolve('<html>')),
|
||||
};
|
||||
|
||||
handler = new HtmlViewHandler(templateEngine, templates);
|
||||
handler = new HtmlViewHandler(index, templateEngine, templates);
|
||||
});
|
||||
|
||||
it('rejects non-GET requests.', async(): Promise<void> => {
|
||||
@ -64,5 +70,17 @@ describe('An HtmlViewHandler', (): void => {
|
||||
const result = await handler.handle({ operation });
|
||||
expect(result.metadata.contentType).toBe(TEXT_HTML);
|
||||
await expect(readableToString(result.data)).resolves.toBe('<html>');
|
||||
expect(templateEngine.render).toHaveBeenCalledTimes(1);
|
||||
expect(templateEngine.render)
|
||||
.toHaveBeenLastCalledWith({ idpIndex, authenticating: false }, { templateFile: '/templates/login.html.ejs' });
|
||||
});
|
||||
|
||||
it('sets authenticating to true if there is an active interaction.', async(): Promise<void> => {
|
||||
const result = await handler.handle({ operation, oidcInteraction: {} as any });
|
||||
expect(result.metadata.contentType).toBe(TEXT_HTML);
|
||||
await expect(readableToString(result.data)).resolves.toBe('<html>');
|
||||
expect(templateEngine.render).toHaveBeenCalledTimes(1);
|
||||
expect(templateEngine.render)
|
||||
.toHaveBeenLastCalledWith({ idpIndex, authenticating: true }, { templateFile: '/templates/login.html.ejs' });
|
||||
});
|
||||
});
|
||||
|
@ -0,0 +1,62 @@
|
||||
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
|
||||
import type {
|
||||
InteractionHandler,
|
||||
InteractionHandlerInput,
|
||||
} from '../../../../src/identity/interaction/InteractionHandler';
|
||||
import { LocationInteractionHandler } from '../../../../src/identity/interaction/LocationInteractionHandler';
|
||||
import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError';
|
||||
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
|
||||
import { readJsonStream } from '../../../../src/util/StreamUtil';
|
||||
|
||||
describe('A LocationInteractionHandler', (): void => {
|
||||
const representation = new BasicRepresentation();
|
||||
const input: InteractionHandlerInput = {
|
||||
operation: {
|
||||
target: { path: 'http://example.com/target' },
|
||||
preferences: {},
|
||||
method: 'GET',
|
||||
body: new BasicRepresentation(),
|
||||
},
|
||||
};
|
||||
let source: jest.Mocked<InteractionHandler>;
|
||||
let handler: LocationInteractionHandler;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
source = {
|
||||
canHandle: jest.fn(),
|
||||
handle: jest.fn().mockResolvedValue(representation),
|
||||
} as any;
|
||||
|
||||
handler = new LocationInteractionHandler(source);
|
||||
});
|
||||
|
||||
it('calls the source canHandle function.', async(): Promise<void> => {
|
||||
await expect(handler.canHandle(input)).resolves.toBeUndefined();
|
||||
expect(source.canHandle).toHaveBeenCalledTimes(1);
|
||||
expect(source.canHandle).toHaveBeenLastCalledWith(input);
|
||||
|
||||
source.canHandle.mockRejectedValueOnce(new Error('bad input'));
|
||||
await expect(handler.canHandle(input)).rejects.toThrow('bad input');
|
||||
});
|
||||
|
||||
it('returns the source output.', async(): Promise<void> => {
|
||||
await expect(handler.handle(input)).resolves.toBe(representation);
|
||||
expect(source.handle).toHaveBeenCalledTimes(1);
|
||||
expect(source.handle).toHaveBeenLastCalledWith(input);
|
||||
});
|
||||
|
||||
it('returns a location object in case of redirect errors.', async(): Promise<void> => {
|
||||
const location = 'http://example.com/foo';
|
||||
source.handle.mockRejectedValueOnce(new FoundHttpError(location));
|
||||
|
||||
const response = await handler.handle(input);
|
||||
expect(response.metadata.identifier.value).toEqual(input.operation.target.path);
|
||||
await expect(readJsonStream(response.data)).resolves.toEqual({ location });
|
||||
});
|
||||
|
||||
it('rethrows non-redirect errors.', async(): Promise<void> => {
|
||||
source.handle.mockRejectedValueOnce(new NotFoundHttpError());
|
||||
|
||||
await expect(handler.handle(input)).rejects.toThrow(NotFoundHttpError);
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user