mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Add support for pod owners
This commit is contained in:
parent
4230db5038
commit
cd07338ce7
@ -2,10 +2,55 @@
|
||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
|
||||
"@graph": [
|
||||
{
|
||||
"comment": "This route is only used when creating new pod resources as no further interactions are supported.",
|
||||
"@id": "urn:solid-server:default:AccountPodIdRoute",
|
||||
"@type": "BasePodIdRoute",
|
||||
"base": { "@id": "urn:solid-server:default:AccountPodRoute" }
|
||||
"comment": "Handles updating pod settings.",
|
||||
"@id": "urn:solid-server:default:AccountPodIdRouter",
|
||||
"@type": "AuthorizedRouteHandler",
|
||||
"route": {
|
||||
"@id": "urn:solid-server:default:AccountPodIdRoute",
|
||||
"@type": "BasePodIdRoute",
|
||||
"base": { "@id": "urn:solid-server:default:AccountPodRoute" }
|
||||
},
|
||||
"source":{
|
||||
"@type": "ViewInteractionHandler",
|
||||
"source": {
|
||||
"@id": "urn:solid-server:default:PodResourceHandler",
|
||||
"@type": "UpdateOwnerHandler",
|
||||
"podStore": { "@id": "urn:solid-server:default:PodStore" },
|
||||
"podRoute": { "@id": "urn:solid-server:default:AccountPodIdRoute" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
"@id": "urn:solid-server:default:MetadataWriter",
|
||||
"@type": "ParallelHandler",
|
||||
"handlers": [
|
||||
{
|
||||
"comment": "Adds owner link headers to responses.",
|
||||
"@id": "urn:solid-server:default:MetadataWriter_Owner",
|
||||
"@type": "OwnerMetadataWriter",
|
||||
"podStore": { "@id": "urn:solid-server:default:PodStore" },
|
||||
"storageStrategy": { "@id": "urn:solid-server:default:StorageLocationStrategy" }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"@id": "urn:solid-server:default:InteractionRouteHandler",
|
||||
"@type": "WaterfallHandler",
|
||||
"handlers": [{ "@id": "urn:solid-server:default:AccountPodIdRouter" }]
|
||||
},
|
||||
|
||||
{
|
||||
"@id": "urn:solid-server:default:HtmlViewHandler",
|
||||
"@type": "HtmlViewHandler",
|
||||
"templates": [{
|
||||
"@id": "urn:solid-server:default:UpdatePodHtml",
|
||||
"@type": "HtmlViewEntry",
|
||||
"filePath": "@css:templates/identity/account/pod-settings.html.ejs",
|
||||
"route": { "@id": "urn:solid-server:default:AccountPodIdRoute" }
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -6,7 +6,6 @@
|
||||
"@id": "urn:solid-server:default:OwnerPermissionReader",
|
||||
"@type": "OwnerPermissionReader",
|
||||
"podStore": { "@id": "urn:solid-server:default:PodStore" },
|
||||
"webIdStore": { "@id": "urn:solid-server:default:WebIdStore" },
|
||||
"storageStrategy": { "@id": "urn:solid-server:default:StorageLocationStrategy" }
|
||||
}
|
||||
]
|
||||
|
@ -122,7 +122,26 @@ GET requests return all pods created by this account in the following format:
|
||||
}
|
||||
```
|
||||
|
||||
Creates a Solid pod for the account on POST requests.
|
||||
The URL value is the resource URL corresponding to the link with this WebID.
|
||||
Doing a GET request to this resource will return the base URl of the pod, and all its owners of a pod, as shown below.
|
||||
You can send a POST request to this resource with a `webId` and `visible: boolean` field
|
||||
to add/update an owner and set its visibility.
|
||||
Visibility determines whether the owner is exposed through a link header when requesting the pod.
|
||||
You can also send a POST request to this resource with a `webId` and `remove: true` field to remove the owner.
|
||||
|
||||
```json
|
||||
{
|
||||
"baseUrl": "http://localhost:3000/my-pod/",
|
||||
"owners": [
|
||||
{
|
||||
"webId": "http://localhost:3000/my-pod/profile/card#me",
|
||||
"visible": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
POST requests to `controls.account.pod` create a Solid pod for the account.
|
||||
The only required field is `name`, which will determine the name of the pod.
|
||||
|
||||
Additionally, a `settings` object can be sent along,
|
||||
|
@ -1,7 +1,6 @@
|
||||
import type { AuxiliaryIdentifierStrategy } from '../http/auxiliary/AuxiliaryIdentifierStrategy';
|
||||
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
|
||||
import type { PodStore } from '../identity/interaction/pod/util/PodStore';
|
||||
import type { WebIdStore } from '../identity/interaction/webid/util/WebIdStore';
|
||||
import { getLoggerFor } from '../logging/LogUtil';
|
||||
import type { StorageLocationStrategy } from '../server/description/StorageLocationStrategy';
|
||||
import { filter } from '../util/IterableUtil';
|
||||
@ -12,21 +11,19 @@ import type { AclPermissionSet } from './permissions/AclPermissionSet';
|
||||
import type { PermissionMap } from './permissions/Permissions';
|
||||
|
||||
/**
|
||||
* Allows control access if the request is being made by the owner of the pod containing the resource.
|
||||
* Allows control access if the request is being made by an owner of the pod containing the resource.
|
||||
*/
|
||||
export class OwnerPermissionReader extends PermissionReader {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly podStore: PodStore;
|
||||
private readonly webIdStore: WebIdStore;
|
||||
private readonly authStrategy: AuxiliaryIdentifierStrategy;
|
||||
private readonly storageStrategy: StorageLocationStrategy;
|
||||
|
||||
public constructor(podStore: PodStore, webIdStore: WebIdStore, authStrategy: AuxiliaryIdentifierStrategy,
|
||||
public constructor(podStore: PodStore, authStrategy: AuxiliaryIdentifierStrategy,
|
||||
storageStrategy: StorageLocationStrategy) {
|
||||
super();
|
||||
this.podStore = podStore;
|
||||
this.webIdStore = webIdStore;
|
||||
this.authStrategy = authStrategy;
|
||||
this.storageStrategy = storageStrategy;
|
||||
}
|
||||
@ -95,14 +92,19 @@ export class OwnerPermissionReader extends PermissionReader {
|
||||
protected async findOwners(pods: string[]): Promise<Record<string, string[]>> {
|
||||
const owners: Record<string, string[]> = {};
|
||||
// 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}`);
|
||||
for (const baseUrl of new Set(pods)) {
|
||||
const pod = await this.podStore.findByBaseUrl(baseUrl);
|
||||
if (!pod) {
|
||||
this.logger.error(`Unable to find pod ${baseUrl}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
owners[pod] = (await this.webIdStore.findLinks(owner)).map((link): string => link.webId);
|
||||
const podOwners = await this.podStore.getOwners(pod.id);
|
||||
if (!podOwners) {
|
||||
this.logger.error(`Unable to find owners for ${baseUrl}`);
|
||||
continue;
|
||||
}
|
||||
owners[baseUrl] = podOwners.map((owner): string => owner.webId);
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
|
66
src/identity/interaction/pod/UpdateOwnerHandler.ts
Normal file
66
src/identity/interaction/pod/UpdateOwnerHandler.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { boolean, object, string } from 'yup';
|
||||
import type { ResourceIdentifier } from '../../../http/representation/ResourceIdentifier';
|
||||
import type { EmptyObject } from '../../../util/map/MapUtil';
|
||||
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 type { PodIdRoute } from './PodIdRoute';
|
||||
import type { PodStore } from './util/PodStore';
|
||||
|
||||
const inSchema = object({
|
||||
webId: string().trim().required(),
|
||||
visible: boolean().optional().default(false),
|
||||
// If true: remove the WebID as owner
|
||||
remove: boolean().optional().default(false),
|
||||
});
|
||||
|
||||
/**
|
||||
* Responsible for adding/updating/deleting owners in pods.
|
||||
*/
|
||||
export class UpdateOwnerHandler extends JsonInteractionHandler implements JsonView {
|
||||
private readonly podStore: PodStore;
|
||||
private readonly podRoute: PodIdRoute;
|
||||
|
||||
public constructor(podStore: PodStore, podRoute: PodIdRoute) {
|
||||
super();
|
||||
this.podStore = podStore;
|
||||
this.podRoute = podRoute;
|
||||
}
|
||||
|
||||
public async getView({ accountId, target }: JsonInteractionHandlerInput): Promise<JsonRepresentation> {
|
||||
const pod = await this.findVerifiedPod(target, accountId);
|
||||
const owners = await this.podStore.getOwners(pod.id);
|
||||
|
||||
return { json: { ...parseSchema(inSchema), baseUrl: pod?.baseUrl, owners }};
|
||||
}
|
||||
|
||||
public async handle(input: JsonInteractionHandlerInput): Promise<JsonRepresentation<EmptyObject>> {
|
||||
const { accountId, target, json } = input;
|
||||
|
||||
const { webId, visible, remove } = await validateWithError(inSchema, json);
|
||||
const pod = await this.findVerifiedPod(target, accountId);
|
||||
|
||||
if (remove) {
|
||||
await this.podStore.removeOwner(pod.id, webId);
|
||||
} else {
|
||||
await this.podStore.updateOwner(pod.id, webId, visible);
|
||||
}
|
||||
|
||||
return { json: {}};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the pod ID from the path and find the associated pod.
|
||||
* Asserts that the given account ID is the creator of this pod.
|
||||
*/
|
||||
protected async findVerifiedPod(target: ResourceIdentifier, accountId?: string):
|
||||
Promise<{ id: string; baseUrl: string; accountId: string }> {
|
||||
const { podId } = parsePath(this.podRoute, target.path);
|
||||
const pod = await this.podStore.get(podId);
|
||||
verifyAccountId(accountId, pod?.accountId);
|
||||
return { id: podId, ...pod };
|
||||
}
|
||||
}
|
@ -15,21 +15,38 @@ const STORAGE_DESCRIPTION = {
|
||||
accountId: `id:${ACCOUNT_TYPE}`,
|
||||
} as const;
|
||||
|
||||
const OWNER_TYPE = 'owner';
|
||||
const OWNER_DESCRIPTION = {
|
||||
webId: 'string',
|
||||
visible: 'boolean',
|
||||
podId: `id:${STORAGE_TYPE}`,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Adds the initial WebID as the owner of the pod.
|
||||
* By default, this owner is not exposed through a link header.
|
||||
* This can be changed by setting the constructor `visible` parameter to `true`.
|
||||
*/
|
||||
export class BasePodStore extends Initializer implements PodStore {
|
||||
private readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly storage: AccountLoginStorage<{ [STORAGE_TYPE]: typeof STORAGE_DESCRIPTION }>;
|
||||
private readonly storage: AccountLoginStorage<{
|
||||
[STORAGE_TYPE]: typeof STORAGE_DESCRIPTION;
|
||||
[OWNER_TYPE]: typeof OWNER_DESCRIPTION; }>;
|
||||
|
||||
private readonly manager: PodManager;
|
||||
private readonly visible: boolean;
|
||||
|
||||
private initialized = false;
|
||||
|
||||
public constructor(storage: AccountLoginStorage<any>, manager: PodManager) {
|
||||
public constructor(storage: AccountLoginStorage<any>, manager: PodManager, visible = false) {
|
||||
super();
|
||||
this.storage = storage;
|
||||
this.visible = visible;
|
||||
this.manager = manager;
|
||||
}
|
||||
|
||||
@ -42,6 +59,8 @@ export class BasePodStore extends Initializer implements PodStore {
|
||||
await this.storage.defineType(STORAGE_TYPE, STORAGE_DESCRIPTION, false);
|
||||
await this.storage.createIndex(STORAGE_TYPE, 'accountId');
|
||||
await this.storage.createIndex(STORAGE_TYPE, 'baseUrl');
|
||||
await this.storage.defineType(OWNER_TYPE, OWNER_DESCRIPTION, false);
|
||||
await this.storage.createIndex(OWNER_TYPE, 'podId');
|
||||
this.initialized = true;
|
||||
} catch (cause: unknown) {
|
||||
throw new InternalServerError(`Error defining pods in storage: ${createErrorMessage(cause)}`,
|
||||
@ -53,6 +72,7 @@ export class BasePodStore extends Initializer implements PodStore {
|
||||
// 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 });
|
||||
await this.storage.create(OWNER_TYPE, { podId: pod.id, webId: settings.webId, visible: this.visible });
|
||||
|
||||
try {
|
||||
await this.manager.createPod(settings, overwrite);
|
||||
@ -66,16 +86,56 @@ export class BasePodStore extends Initializer implements PodStore {
|
||||
return pod.id;
|
||||
}
|
||||
|
||||
public async findAccount(baseUrl: string): Promise<string | undefined> {
|
||||
public async get(id: string): Promise<{ baseUrl: string; accountId: string } | undefined> {
|
||||
const pod = await this.storage.get(STORAGE_TYPE, id);
|
||||
if (!pod) {
|
||||
return;
|
||||
}
|
||||
return { baseUrl: pod.baseUrl, accountId: pod.accountId };
|
||||
}
|
||||
|
||||
public async findByBaseUrl(baseUrl: string): Promise<{ id: string; accountId: string } | undefined> {
|
||||
const result = await this.storage.find(STORAGE_TYPE, { baseUrl });
|
||||
if (result.length === 0) {
|
||||
return;
|
||||
}
|
||||
return result[0].accountId;
|
||||
return { id: result[0].id, accountId: 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 }));
|
||||
}
|
||||
|
||||
public async getOwners(id: string): Promise<{ webId: string; visible: boolean }[] | undefined> {
|
||||
const results = await this.storage.find(OWNER_TYPE, { podId: id });
|
||||
if (results.length === 0) {
|
||||
return;
|
||||
}
|
||||
return results.map((result): { webId: string; visible: boolean } =>
|
||||
({ webId: result.webId, visible: result.visible }));
|
||||
}
|
||||
|
||||
public async updateOwner(id: string, webId: string, visible: boolean): Promise<void> {
|
||||
// Need to first check if there already is an owner with the given WebID
|
||||
// so we know if we need to create or update.
|
||||
const matches = await this.storage.find(OWNER_TYPE, { webId, podId: id });
|
||||
if (matches.length === 0) {
|
||||
await this.storage.create(OWNER_TYPE, { webId, visible, podId: id });
|
||||
} else {
|
||||
await this.storage.setField(OWNER_TYPE, matches[0].id, 'visible', visible);
|
||||
}
|
||||
}
|
||||
|
||||
public async removeOwner(id: string, webId: string): Promise<void> {
|
||||
const owners = await this.storage.find(OWNER_TYPE, { podId: id });
|
||||
const match = owners.find((owner): boolean => owner.webId === webId);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
if (owners.length === 1) {
|
||||
throw new BadRequestHttpError('Unable to remove the last owner of a pod.');
|
||||
}
|
||||
await this.storage.delete(OWNER_TYPE, match.id);
|
||||
}
|
||||
}
|
||||
|
75
src/identity/interaction/pod/util/OwnerMetadataWriter.ts
Normal file
75
src/identity/interaction/pod/util/OwnerMetadataWriter.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { Util } from 'n3';
|
||||
import type { MetadataWriterInput } from '../../../../http/output/metadata/MetadataWriter';
|
||||
import { MetadataWriter } from '../../../../http/output/metadata/MetadataWriter';
|
||||
import type { ResourceIdentifier } from '../../../../http/representation/ResourceIdentifier';
|
||||
import { getLoggerFor } from '../../../../logging/LogUtil';
|
||||
import type { StorageLocationStrategy } from '../../../../server/description/StorageLocationStrategy';
|
||||
import { createErrorMessage } from '../../../../util/errors/ErrorUtil';
|
||||
import { addHeader } from '../../../../util/HeaderUtil';
|
||||
import type { PodStore } from './PodStore';
|
||||
import isBlankNode = Util.isBlankNode;
|
||||
|
||||
/**
|
||||
* Adds link headers indicating who the owners are when accessing the base URL of a pod.
|
||||
* Only owners that have decided to be visible will be shown.
|
||||
*
|
||||
* Solid, §4.1: "When a server wants to advertise the owner of a storage,
|
||||
* the server MUST include the Link header with rel="http://www.w3.org/ns/solid/terms#owner"
|
||||
* targeting the URI of the owner in the response of HTTP HEAD or GET requests targeting the root container."
|
||||
* https://solidproject.org/TR/2022/protocol-20221231#server-storage-link-owner
|
||||
*/
|
||||
export class OwnerMetadataWriter extends MetadataWriter {
|
||||
protected logger = getLoggerFor(this);
|
||||
|
||||
protected podStore: PodStore;
|
||||
protected storageStrategy: StorageLocationStrategy;
|
||||
|
||||
public constructor(podStore: PodStore, storageStrategy: StorageLocationStrategy) {
|
||||
super();
|
||||
this.podStore = podStore;
|
||||
this.storageStrategy = storageStrategy;
|
||||
}
|
||||
|
||||
public async handle({ metadata, response }: MetadataWriterInput): Promise<void> {
|
||||
// Doing all checks here instead of in `canHandle` as this is currently used in a ParallelHandler,
|
||||
// which doesn't correctly check the canHandle/handle combination.
|
||||
if (isBlankNode(metadata.identifier)) {
|
||||
// Blank nodes indicate errors
|
||||
this.logger.debug('Skipping owner link headers as metadata identifier is a blank node.');
|
||||
return;
|
||||
}
|
||||
const identifier = { path: metadata.identifier.value };
|
||||
|
||||
let storageIdentifier: ResourceIdentifier;
|
||||
try {
|
||||
storageIdentifier = await this.storageStrategy.getStorageIdentifier(identifier);
|
||||
} catch (error: unknown) {
|
||||
this.logger
|
||||
.debug(`Skipping owner link headers as no storage identifier could be found: ${createErrorMessage(error)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only need to expose headers when requesting the base URl of the pod
|
||||
if (identifier.path !== storageIdentifier.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pod = await this.podStore.findByBaseUrl(identifier.path);
|
||||
if (!pod) {
|
||||
this.logger.debug(`No pod object found for base URL ${identifier.path}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const owners = await this.podStore.getOwners(pod.id);
|
||||
if (!owners) {
|
||||
this.logger.error(`Unable to find owners for pod ${identifier.path}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { webId, visible } of owners) {
|
||||
if (visible) {
|
||||
addHeader(response, 'Link', `<${webId}>; rel="http://www.w3.org/ns/solid/terms#owner"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -2,12 +2,14 @@ import type { PodSettings } from '../../../../pods/settings/PodSettings';
|
||||
|
||||
/**
|
||||
* Can be used to create new pods and find relevant information.
|
||||
* Also keeps track of the owners of a pod.
|
||||
* The `visible` parameter indicates if an owner should be publicly exposed or not.
|
||||
*/
|
||||
export interface PodStore {
|
||||
/**
|
||||
* Creates a new pod and updates the account accordingly.
|
||||
*
|
||||
* @param accountId - Identifier of the account that is creating the account..
|
||||
* @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.
|
||||
*
|
||||
@ -15,12 +17,19 @@ export interface PodStore {
|
||||
*/
|
||||
create: (accountId: string, settings: PodSettings, overwrite: boolean) => Promise<string>;
|
||||
|
||||
/**
|
||||
* Returns the baseURl and account that created the pod for the given pod ID.
|
||||
*
|
||||
* @param id - ID of the pod.
|
||||
*/
|
||||
get: (id: string) => Promise<{ baseUrl: string; accountId: string } | undefined>;
|
||||
|
||||
/**
|
||||
* Find the ID of the account that created the given pod.
|
||||
*
|
||||
* @param baseUrl - The pod base URL.
|
||||
*/
|
||||
findAccount: (baseUrl: string) => Promise<string | undefined>;
|
||||
findByBaseUrl: (baseUrl: string) => Promise<{ id: string; accountId: string } | undefined>;
|
||||
|
||||
/**
|
||||
* Find all the pod resources created by the given account ID.
|
||||
@ -28,4 +37,32 @@ export interface PodStore {
|
||||
* @param accountId - Account ID to find pod resources for.
|
||||
*/
|
||||
findPods: (accountId: string) => Promise<{ id: string; baseUrl: string }[]>;
|
||||
|
||||
/**
|
||||
* Find all owners for the given pod ID.
|
||||
*
|
||||
* @param id - ID of the pod.
|
||||
*/
|
||||
getOwners: (id: string) => Promise<{ webId: string; visible: boolean }[] | undefined>;
|
||||
|
||||
/**
|
||||
* Add or update an owner of a pod.
|
||||
* In case there already is an owner with this WebID, it will be updated,
|
||||
* otherwise a new owner will be added.
|
||||
*
|
||||
* @param id - ID of the pod.
|
||||
* @param webId - WebID of the owner.
|
||||
* @param visible - Whether the owner wants to be exposed or not.
|
||||
*/
|
||||
updateOwner: (id: string, webId: string, visible: boolean) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Remove an owner from a pod.
|
||||
* This should not remove the last owner as a pod always needs to have at least one owner.
|
||||
* https://solidproject.org/TR/2022/protocol-20221231#server-storage-track-owner
|
||||
*
|
||||
* @param id - ID of the pod.
|
||||
* @param webId - WebID of the owner.
|
||||
*/
|
||||
removeOwner: (id: string, webId: string) => Promise<void>;
|
||||
}
|
||||
|
@ -97,9 +97,9 @@ export class LinkWebIdHandler extends JsonInteractionHandler<OutType> implements
|
||||
// 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;
|
||||
const baseUrl = await this.storageStrategy.getStorageIdentifier({ path: webId });
|
||||
const pod = await this.podStore.findByBaseUrl(baseUrl.path);
|
||||
isCreator = accountId === pod?.accountId;
|
||||
} catch {
|
||||
// Probably a WebID not hosted on the server
|
||||
}
|
||||
|
@ -202,11 +202,13 @@ export * from './identity/interaction/password/UpdatePasswordHandler';
|
||||
|
||||
// Identity/Interaction/Pod/Util
|
||||
export * from './identity/interaction/pod/util/BasePodStore';
|
||||
export * from './identity/interaction/pod/util/OwnerMetadataWriter';
|
||||
export * from './identity/interaction/pod/util/PodStore';
|
||||
|
||||
// Identity/Interaction/Pod
|
||||
export * from './identity/interaction/pod/CreatePodHandler';
|
||||
export * from './identity/interaction/pod/PodIdRoute';
|
||||
export * from './identity/interaction/pod/UpdateOwnerHandler';
|
||||
|
||||
// Identity/Interaction/Routing
|
||||
export * from './identity/interaction/routing/AbsolutePathInteractionRoute';
|
||||
|
82
templates/identity/account/pod-settings.html.ejs
Normal file
82
templates/identity/account/pod-settings.html.ejs
Normal file
@ -0,0 +1,82 @@
|
||||
<h1>Pod settings</h1>
|
||||
<p class="error" id="error"></p>
|
||||
<h2 id="pod"></h2>
|
||||
|
||||
<h3>Owners</h3>
|
||||
<p>
|
||||
All these WebIDs have full control access over all resources in the pod.
|
||||
If visible, these will be returned as a header to all requests targeting the pod.
|
||||
</p>
|
||||
<ul id="owners"></ul>
|
||||
|
||||
<form method="post" id="mainForm">
|
||||
<p class="error" id="error"></p>
|
||||
|
||||
<fieldset>
|
||||
<p>Add the WebID to add to the list of owners</p>
|
||||
<ol>
|
||||
<li>
|
||||
<label for="owner">WebID:</label>
|
||||
<input id="owner" type="text" name="owner" autofocus>
|
||||
</li>
|
||||
</ol>
|
||||
</fieldset>
|
||||
|
||||
<p class="actions">
|
||||
<button type="submit" name="submit">Add owner</button>
|
||||
<button type="button" id="account-link">Back</button>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
|
||||
<script>
|
||||
const elements = getElements('owners');
|
||||
(async() => {
|
||||
const { baseUrl, owners, controls } = await fetchJson('', '<%= idpIndex %>');
|
||||
updateElement('pod', baseUrl, { innerText: true });
|
||||
|
||||
setRedirectClick('account-link', controls.html.account.account);
|
||||
|
||||
for (const { webId, visible } of owners) {
|
||||
const li = document.createElement('li');
|
||||
li.insertAdjacentHTML('beforeend', `<strong>${webId}</strong>`);
|
||||
elements.owners.append(li);
|
||||
|
||||
// Visibility element
|
||||
const label = document.createElement('label');
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.checked = visible;
|
||||
label.append(checkbox);
|
||||
label.append('Visible');
|
||||
li.append(label);
|
||||
checkbox.addEventListener('click', async() => {
|
||||
const res = await fetch('', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ webId, visible: checkbox.checked }),
|
||||
});
|
||||
if (res.status >= 400) {
|
||||
const error = await res.json();
|
||||
setError(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete element
|
||||
const fetchParams = {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ webId, remove: true }),
|
||||
}
|
||||
const del = createUrlDeleteElement(li, '', fetchParams,
|
||||
`Are you sure you want to remove ${webId} as an owner of ${pod}?`);
|
||||
li.append(' ');
|
||||
li.append(del);
|
||||
}
|
||||
|
||||
addPostListener(async() => {
|
||||
await postJsonForm('');
|
||||
location.reload();
|
||||
});
|
||||
})();
|
||||
</script>
|
@ -64,7 +64,7 @@
|
||||
setVisibility('pods', false);
|
||||
} else {
|
||||
updateElement('createPod', controls.html.account.createPod, { href: true });
|
||||
showAccountInfo(elements.podEntries, pods, false, false);
|
||||
showAccountInfo(elements.podEntries, pods, true, false);
|
||||
}
|
||||
|
||||
// Update WebID entries
|
||||
@ -104,25 +104,7 @@
|
||||
|
||||
|
||||
if (addDel) {
|
||||
const del = document.createElement('a');
|
||||
del.innerText = '(delete)';
|
||||
del.href = '#';
|
||||
del.addEventListener('click', async() => {
|
||||
if (!confirm(confirmMsg)) {
|
||||
return;
|
||||
}
|
||||
// Delete resource, show error if this fails
|
||||
const res = await fetch(url, { method: 'DELETE' });
|
||||
if (res.status >= 400) {
|
||||
const error = await res.json();
|
||||
setError(error.message);
|
||||
} else {
|
||||
li.remove();
|
||||
if (finishMsg) {
|
||||
setError(finishMsg);
|
||||
}
|
||||
}
|
||||
});
|
||||
const del = createUrlDeleteElement(li, url, { method: 'DELETE' }, confirmMsg, finishMsg);
|
||||
li.append(' ');
|
||||
li.append(del);
|
||||
}
|
||||
|
@ -22,7 +22,7 @@
|
||||
|
||||
<h2 id="users">Getting started as a <em>user</em></h2>
|
||||
<p>
|
||||
<a id="registration-link" href="./.account/password/register/">Sign up for an account</a>
|
||||
<a id="registration-link" href="./.account/login/password/register/">Sign up for an account</a>
|
||||
to get started with your own Pod and WebID.
|
||||
</p>
|
||||
<p>
|
||||
|
@ -22,7 +22,7 @@
|
||||
|
||||
<h2 id="users">Getting started as a <em>user</em></h2>
|
||||
<p id="registration-enabled">
|
||||
<a id="registration-link" href="./.account/password/register/">Sign up for an account</a>
|
||||
<a id="registration-link" href="./.account/login/password/register/">Sign up for an account</a>
|
||||
to get started with your own Pod and WebID.
|
||||
</p>
|
||||
<p id="registration-disabled" class="hidden">
|
||||
|
@ -22,7 +22,7 @@
|
||||
|
||||
<h2 id="users">Getting started as a <em>user</em></h2>
|
||||
<p id="registration-enabled">
|
||||
<a id="registration-link" href="./.account/password/register/">Sign up for an account</a>
|
||||
<a id="registration-link" href="./.account/login/password/register/">Sign up for an account</a>
|
||||
to get started with your own Pod and WebID.
|
||||
</p>
|
||||
<p id="registration-disabled" class="hidden">
|
||||
|
@ -169,3 +169,37 @@ function validatePasswordConfirmation(passwordId, formId = 'mainForm', confirmPa
|
||||
throw new Error('Password confirmation does not match the password!');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a `(delete)` link that can be clicked to remove a resource and update the HTML accordingly.
|
||||
*
|
||||
* @param parent - The HTML object that needs to be removed when the resource is removed.
|
||||
* @param url - The URL of the resource.
|
||||
* @param fetchParams - Parameters to pass to the fetch request that would remove the resource.
|
||||
* @param confirmMsg - Message to show to confirm that the resource needs to be deleted.
|
||||
* @param finishMsg - Optional message to show in the error field when the resource was removed.
|
||||
*
|
||||
* @returns The HTML object representing the `(delete)` link.
|
||||
*/
|
||||
function createUrlDeleteElement(parent, url, fetchParams, confirmMsg, finishMsg) {
|
||||
const del = document.createElement('a');
|
||||
del.innerText = '(delete)';
|
||||
del.href = '#';
|
||||
del.addEventListener('click', async() => {
|
||||
if (!confirm(confirmMsg)) {
|
||||
return;
|
||||
}
|
||||
// Delete resource, show error if this fails
|
||||
const res = await fetch(url, fetchParams);
|
||||
if (res.status >= 400) {
|
||||
const error = await res.json();
|
||||
setError(error.message);
|
||||
} else {
|
||||
parent.remove();
|
||||
if (finishMsg) {
|
||||
setError(finishMsg);
|
||||
}
|
||||
}
|
||||
});
|
||||
return del;
|
||||
}
|
||||
|
@ -31,6 +31,7 @@ describe('A server with account management', (): void => {
|
||||
};
|
||||
let passwordResource: string;
|
||||
let pod: string;
|
||||
let podResource: string;
|
||||
let webId: string;
|
||||
|
||||
beforeAll(async(): Promise<void> => {
|
||||
@ -243,7 +244,7 @@ describe('A server with account management', (): void => {
|
||||
expect(json.podResource).toBeDefined();
|
||||
expect(json.webId).toBeDefined();
|
||||
expect(json.webIdResource).toBeDefined();
|
||||
({ pod, webId } = json);
|
||||
({ pod, webId, podResource } = json);
|
||||
|
||||
// Verify if the content was added to the profile
|
||||
res = await fetch(controls.account.pod, { headers: { cookie }});
|
||||
@ -254,6 +255,82 @@ describe('A server with account management', (): void => {
|
||||
expect((await res.json()).webIdLinks[webId]).toBeDefined();
|
||||
});
|
||||
|
||||
it('can not remove the last owner of a pod.', async(): Promise<void> => {
|
||||
const res = await fetch(podResource, {
|
||||
method: 'POST',
|
||||
headers: { cookie, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ webId, remove: true }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
await expect(res.text()).resolves.toContain('Unable to remove the last owner of a pod.');
|
||||
});
|
||||
|
||||
it('can add an owner to a pod.', async(): Promise<void> => {
|
||||
let res = await fetch(podResource, {
|
||||
method: 'POST',
|
||||
headers: { cookie, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ webId: 'http://example.com/other/webID', visible: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Verify that the new owner was added
|
||||
res = await fetch(podResource, { headers: { cookie }});
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).owners).toEqual([
|
||||
{ webId, visible: false },
|
||||
{ webId: 'http://example.com/other/webID', visible: true },
|
||||
]);
|
||||
|
||||
// Verify only the new owner is exposed through a link header
|
||||
res = await fetch(pod);
|
||||
expect(res.status).toBe(200);
|
||||
const owners = res.headers.get('link')?.split(',')
|
||||
.filter((header): boolean => header.includes('rel="http://www.w3.org/ns/solid/terms#owner"'))
|
||||
.map((header): string => /<([^>]+)>/u.exec(header)![1]);
|
||||
expect(owners).toEqual([ 'http://example.com/other/webID' ]);
|
||||
});
|
||||
|
||||
it('can update the visibility of an existing pod owner.', async(): Promise<void> => {
|
||||
let res = await fetch(podResource, {
|
||||
method: 'POST',
|
||||
headers: { cookie, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ webId, visible: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Verify that the visibility was changed
|
||||
res = await fetch(podResource, { headers: { cookie }});
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).owners).toEqual([
|
||||
{ webId, visible: true },
|
||||
{ webId: 'http://example.com/other/webID', visible: true },
|
||||
]);
|
||||
|
||||
// Verify both WebIDs are now visible
|
||||
res = await fetch(pod);
|
||||
expect(res.status).toBe(200);
|
||||
const owners = res.headers.get('link')?.split(',')
|
||||
.filter((header): boolean => header.includes('rel="http://www.w3.org/ns/solid/terms#owner"'))
|
||||
.map((header): string => /<([^>]+)>/u.exec(header)![1]);
|
||||
expect(owners).toEqual([ webId, 'http://example.com/other/webID' ]);
|
||||
});
|
||||
|
||||
it('can remove an owner from a pod.', async(): Promise<void> => {
|
||||
let res = await fetch(podResource, {
|
||||
method: 'POST',
|
||||
headers: { cookie, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ webId: 'http://example.com/other/webID', remove: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Verify that the new owner was added
|
||||
res = await fetch(podResource, { headers: { cookie }});
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).owners).toEqual([
|
||||
{ webId, visible: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not store any data if creating a pod fails on the same account.', async(): Promise<void> => {
|
||||
const oldPods = (await (await fetch(controls.account.pod, { headers: { cookie }})).json()).pods;
|
||||
const oldWebIdLinks = (await (await fetch(controls.account.webId, { headers: { cookie }})).json()).webIdLinks;
|
||||
|
@ -5,7 +5,6 @@ import type { AccessMap } from '../../../src/authorization/permissions/Permissio
|
||||
import { AuxiliaryIdentifierStrategy } from '../../../src/http/auxiliary/AuxiliaryIdentifierStrategy';
|
||||
import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier';
|
||||
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 { compareMaps } from '../../util/Util';
|
||||
@ -18,7 +17,6 @@ describe('An OwnerPermissionReader', (): void => {
|
||||
let identifier: ResourceIdentifier;
|
||||
let requestedModes: AccessMap;
|
||||
let podStore: jest.Mocked<PodStore>;
|
||||
let webIdStore: jest.Mocked<WebIdStore>;
|
||||
let aclStrategy: jest.Mocked<AuxiliaryIdentifierStrategy>;
|
||||
let storageStrategy: jest.Mocked<StorageLocationStrategy>;
|
||||
let reader: OwnerPermissionReader;
|
||||
@ -31,13 +29,10 @@ describe('An OwnerPermissionReader', (): void => {
|
||||
requestedModes = new IdentifierSetMultiMap([[ identifier, AclMode.control ]]) as any;
|
||||
|
||||
podStore = {
|
||||
findAccount: jest.fn().mockResolvedValue(accountId),
|
||||
findByBaseUrl: jest.fn().mockResolvedValue(accountId),
|
||||
getOwners: jest.fn().mockResolvedValue([{ webId: owner, visible: false }]),
|
||||
} satisfies Partial<PodStore> as any;
|
||||
|
||||
webIdStore = {
|
||||
findLinks: jest.fn().mockResolvedValue([{ id: '???', webId: owner }]),
|
||||
} satisfies Partial<WebIdStore> as any;
|
||||
|
||||
aclStrategy = {
|
||||
isAuxiliaryIdentifier: jest.fn((id): boolean => id.path.endsWith('.acl')),
|
||||
} satisfies Partial<AuxiliaryIdentifierStrategy> as any;
|
||||
@ -46,7 +41,7 @@ describe('An OwnerPermissionReader', (): void => {
|
||||
getStorageIdentifier: jest.fn().mockResolvedValue(podBaseUrl),
|
||||
};
|
||||
|
||||
reader = new OwnerPermissionReader(podStore, webIdStore, aclStrategy, storageStrategy);
|
||||
reader = new OwnerPermissionReader(podStore, aclStrategy, storageStrategy);
|
||||
});
|
||||
|
||||
it('returns empty permissions for non-ACL resources.', async(): Promise<void> => {
|
||||
@ -64,8 +59,13 @@ describe('An OwnerPermissionReader', (): void => {
|
||||
compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap());
|
||||
});
|
||||
|
||||
it('returns empty permissions if there is no pod owner.', async(): Promise<void> => {
|
||||
podStore.findAccount.mockResolvedValueOnce(undefined);
|
||||
it('returns empty permissions if there is no pod object.', async(): Promise<void> => {
|
||||
podStore.findByBaseUrl.mockResolvedValueOnce(undefined);
|
||||
compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap());
|
||||
});
|
||||
|
||||
it('returns empty permissions if there are no pod owners.', async(): Promise<void> => {
|
||||
podStore.getOwners.mockResolvedValueOnce(undefined);
|
||||
compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap());
|
||||
});
|
||||
|
||||
|
@ -0,0 +1,78 @@
|
||||
import type { PodIdRoute } from '../../../../../src/identity/interaction/pod/PodIdRoute';
|
||||
import { UpdateOwnerHandler } from '../../../../../src/identity/interaction/pod/UpdateOwnerHandler';
|
||||
import { PodStore } from '../../../../../src/identity/interaction/pod/util/PodStore';
|
||||
import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError';
|
||||
|
||||
describe('An UpdateOwnerHandler', (): void => {
|
||||
const id = 'id';
|
||||
const accountId = 'accountId';
|
||||
const baseUrl = 'http://example.com/profile/';
|
||||
const webId = 'http://example.org/profile/card#me';
|
||||
const target = { path: 'http://example.org/account/pod/123/' };
|
||||
let store: jest.Mocked<PodStore>;
|
||||
let route: jest.Mocked<PodIdRoute>;
|
||||
let handler: UpdateOwnerHandler;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
store = {
|
||||
get: jest.fn().mockResolvedValue({ baseUrl, accountId }),
|
||||
getOwners: jest.fn().mockResolvedValue([{ webId, visible: true }]),
|
||||
updateOwner: jest.fn(),
|
||||
removeOwner: jest.fn(),
|
||||
} satisfies Partial<PodStore> as any;
|
||||
|
||||
route = {
|
||||
getPath: jest.fn().mockReturnValue(''),
|
||||
matchPath: jest.fn().mockReturnValue({ accountId, podId: id }),
|
||||
};
|
||||
|
||||
handler = new UpdateOwnerHandler(store, route);
|
||||
});
|
||||
|
||||
it('requires specific input fields and returns all owners.', async(): Promise<void> => {
|
||||
await expect(handler.getView({ accountId, target } as any)).resolves.toEqual({
|
||||
json: {
|
||||
baseUrl,
|
||||
owners: [{ webId, visible: true }],
|
||||
fields: {
|
||||
webId: { required: true, type: 'string' },
|
||||
visible: { required: false, type: 'boolean' },
|
||||
remove: { required: false, type: 'boolean' },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(store.get).toHaveBeenCalledTimes(1);
|
||||
expect(store.get).toHaveBeenLastCalledWith(id);
|
||||
expect(store.getOwners).toHaveBeenCalledTimes(1);
|
||||
expect(store.getOwners).toHaveBeenLastCalledWith(id);
|
||||
});
|
||||
|
||||
it('can update the owner visibility.', async(): Promise<void> => {
|
||||
await expect(handler.handle({ accountId, target, json: { webId, visible: true }} as any))
|
||||
.resolves.toEqual({ json: {}});
|
||||
expect(store.get).toHaveBeenCalledTimes(1);
|
||||
expect(store.get).toHaveBeenLastCalledWith(id);
|
||||
expect(store.updateOwner).toHaveBeenCalledTimes(1);
|
||||
expect(store.updateOwner).toHaveBeenLastCalledWith(id, webId, true);
|
||||
expect(store.removeOwner).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('can remove an owner.', async(): Promise<void> => {
|
||||
await expect(handler.handle({ accountId, target, json: { webId, remove: true }} as any))
|
||||
.resolves.toEqual({ json: {}});
|
||||
expect(store.get).toHaveBeenCalledTimes(1);
|
||||
expect(store.get).toHaveBeenLastCalledWith(id);
|
||||
expect(store.updateOwner).toHaveBeenCalledTimes(0);
|
||||
expect(store.removeOwner).toHaveBeenCalledTimes(1);
|
||||
expect(store.removeOwner).toHaveBeenLastCalledWith(id, webId);
|
||||
});
|
||||
|
||||
it('throws a 404 if the authenticated accountId is not the owner.', async(): Promise<void> => {
|
||||
await expect(handler.handle({ target, json: { webId, remove: true }, accountId: 'otherId' } as any))
|
||||
.rejects.toThrow(NotFoundHttpError);
|
||||
expect(store.get).toHaveBeenCalledTimes(1);
|
||||
expect(store.get).toHaveBeenLastCalledWith(id);
|
||||
expect(store.updateOwner).toHaveBeenCalledTimes(0);
|
||||
expect(store.removeOwner).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
@ -5,15 +5,18 @@ import {
|
||||
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 { BadRequestHttpError } from '../../../../../../src/util/errors/BadRequestHttpError';
|
||||
import { InternalServerError } from '../../../../../../src/util/errors/InternalServerError';
|
||||
|
||||
const STORAGE_TYPE = 'pod';
|
||||
const OWNER_TYPE = 'owner';
|
||||
|
||||
describe('A BasePodStore', (): void => {
|
||||
const accountId = 'accountId';
|
||||
const id = 'id';
|
||||
const baseUrl = 'http://example.com/foo/';
|
||||
const settings: PodSettings = { webId: 'http://example.com/card#me', base: { path: baseUrl }};
|
||||
const webId = 'http://example.com/card#me';
|
||||
const settings: PodSettings = { webId, base: { path: baseUrl }};
|
||||
let storage: jest.Mocked<AccountLoginStorage<any>>;
|
||||
let manager: jest.Mocked<PodManager>;
|
||||
let store: BasePodStore;
|
||||
@ -23,7 +26,9 @@ describe('A BasePodStore', (): void => {
|
||||
defineType: jest.fn().mockResolvedValue({}),
|
||||
createIndex: jest.fn().mockResolvedValue({}),
|
||||
create: jest.fn().mockResolvedValue({ id, baseUrl, accountId }),
|
||||
get: jest.fn().mockResolvedValue({ id, baseUrl, accountId }),
|
||||
find: jest.fn().mockResolvedValue([{ id, baseUrl, accountId }]),
|
||||
setField: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
} satisfies Partial<AccountLoginStorage<any>> as any;
|
||||
|
||||
@ -36,20 +41,26 @@ describe('A BasePodStore', (): void => {
|
||||
|
||||
it('defines the type and indexes in the storage.', async(): Promise<void> => {
|
||||
await expect(store.handle()).resolves.toBeUndefined();
|
||||
expect(storage.defineType).toHaveBeenCalledTimes(1);
|
||||
expect(storage.defineType).toHaveBeenLastCalledWith(STORAGE_TYPE, {
|
||||
expect(storage.defineType).toHaveBeenCalledTimes(2);
|
||||
expect(storage.defineType).toHaveBeenCalledWith(STORAGE_TYPE, {
|
||||
baseUrl: 'string',
|
||||
accountId: `id:${ACCOUNT_TYPE}`,
|
||||
}, false);
|
||||
expect(storage.createIndex).toHaveBeenCalledTimes(2);
|
||||
expect(storage.defineType).toHaveBeenCalledWith(OWNER_TYPE, {
|
||||
webId: 'string',
|
||||
visible: 'boolean',
|
||||
podId: `id:${STORAGE_TYPE}`,
|
||||
}, false);
|
||||
expect(storage.createIndex).toHaveBeenCalledTimes(3);
|
||||
expect(storage.createIndex).toHaveBeenCalledWith(STORAGE_TYPE, 'accountId');
|
||||
expect(storage.createIndex).toHaveBeenCalledWith(STORAGE_TYPE, 'baseUrl');
|
||||
expect(storage.createIndex).toHaveBeenCalledWith(OWNER_TYPE, 'podId');
|
||||
});
|
||||
|
||||
it('can only initialize once.', async(): Promise<void> => {
|
||||
await expect(store.handle()).resolves.toBeUndefined();
|
||||
await expect(store.handle()).resolves.toBeUndefined();
|
||||
expect(storage.defineType).toHaveBeenCalledTimes(1);
|
||||
expect(storage.defineType).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws an error if defining the type goes wrong.', async(): Promise<void> => {
|
||||
@ -59,8 +70,9 @@ describe('A BasePodStore', (): void => {
|
||||
|
||||
it('calls the pod manager to create a pod.', async(): Promise<void> => {
|
||||
await expect(store.create(accountId, settings, false)).resolves.toBe(id);
|
||||
expect(storage.create).toHaveBeenCalledTimes(1);
|
||||
expect(storage.create).toHaveBeenLastCalledWith(STORAGE_TYPE, { accountId, baseUrl });
|
||||
expect(storage.create).toHaveBeenCalledTimes(2);
|
||||
expect(storage.create).toHaveBeenNthCalledWith(1, STORAGE_TYPE, { accountId, baseUrl });
|
||||
expect(storage.create).toHaveBeenNthCalledWith(2, OWNER_TYPE, { podId: id, webId, visible: false });
|
||||
expect(manager.createPod).toHaveBeenCalledTimes(1);
|
||||
expect(manager.createPod).toHaveBeenLastCalledWith(settings, false);
|
||||
});
|
||||
@ -68,14 +80,28 @@ describe('A BasePodStore', (): void => {
|
||||
it('reverts the storage changes if something goes wrong.', async(): Promise<void> => {
|
||||
manager.createPod.mockRejectedValueOnce(new Error('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(storage.create).toHaveBeenCalledTimes(2);
|
||||
expect(storage.create).toHaveBeenNthCalledWith(1, STORAGE_TYPE, { accountId, baseUrl });
|
||||
expect(storage.create).toHaveBeenNthCalledWith(2, OWNER_TYPE, { podId: id, webId, visible: false });
|
||||
expect(manager.createPod).toHaveBeenCalledTimes(1);
|
||||
expect(manager.createPod).toHaveBeenLastCalledWith(settings, false);
|
||||
expect(storage.delete).toHaveBeenCalledTimes(1);
|
||||
expect(storage.delete).toHaveBeenLastCalledWith(STORAGE_TYPE, id);
|
||||
});
|
||||
|
||||
it('returns the pod information.', async(): Promise<void> => {
|
||||
await expect(store.get(id)).resolves.toEqual({ baseUrl, accountId });
|
||||
expect(storage.get).toHaveBeenCalledTimes(1);
|
||||
expect(storage.get).toHaveBeenLastCalledWith(STORAGE_TYPE, id);
|
||||
});
|
||||
|
||||
it('returns undefined if there is no matching pod.', async(): Promise<void> => {
|
||||
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 all the pods for an account.', async(): Promise<void> => {
|
||||
await expect(store.findPods(accountId)).resolves.toEqual([{ id, baseUrl }]);
|
||||
expect(storage.find).toHaveBeenCalledTimes(1);
|
||||
@ -83,15 +109,75 @@ describe('A BasePodStore', (): void => {
|
||||
});
|
||||
|
||||
it('can find the account that created a pod.', async(): Promise<void> => {
|
||||
await expect(store.findAccount(baseUrl)).resolves.toEqual(accountId);
|
||||
await expect(store.findByBaseUrl(baseUrl)).resolves.toEqual({ accountId, id });
|
||||
expect(storage.find).toHaveBeenCalledTimes(1);
|
||||
expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { baseUrl });
|
||||
});
|
||||
|
||||
it('returns undefined if there is no associated account.', async(): Promise<void> => {
|
||||
storage.find.mockResolvedValueOnce([]);
|
||||
await expect(store.findAccount(baseUrl)).resolves.toBeUndefined();
|
||||
await expect(store.findByBaseUrl(baseUrl)).resolves.toBeUndefined();
|
||||
expect(storage.find).toHaveBeenCalledTimes(1);
|
||||
expect(storage.find).toHaveBeenLastCalledWith(STORAGE_TYPE, { baseUrl });
|
||||
});
|
||||
|
||||
it('can return all the owners of a pod.', async(): Promise<void> => {
|
||||
storage.find.mockResolvedValueOnce([{ id: 'id1', webId, visible: true }]);
|
||||
await expect(store.getOwners(id)).resolves.toEqual([{ webId, visible: true }]);
|
||||
expect(storage.find).toHaveBeenCalledTimes(1);
|
||||
expect(storage.find).toHaveBeenLastCalledWith(OWNER_TYPE, { podId: id });
|
||||
});
|
||||
|
||||
it('returns undefined if there are no owners.', async(): Promise<void> => {
|
||||
storage.find.mockResolvedValueOnce([]);
|
||||
await expect(store.getOwners(id)).resolves.toBeUndefined();
|
||||
expect(storage.find).toHaveBeenCalledTimes(1);
|
||||
expect(storage.find).toHaveBeenLastCalledWith(OWNER_TYPE, { podId: id });
|
||||
});
|
||||
|
||||
it('creates a new owner if the update target does not exist yet.', async(): Promise<void> => {
|
||||
storage.find.mockResolvedValueOnce([]);
|
||||
await expect(store.updateOwner(id, webId, true)).resolves.toBeUndefined();
|
||||
expect(storage.find).toHaveBeenCalledTimes(1);
|
||||
expect(storage.find).toHaveBeenLastCalledWith(OWNER_TYPE, { podId: id, webId });
|
||||
expect(storage.create).toHaveBeenCalledTimes(1);
|
||||
expect(storage.create).toHaveBeenLastCalledWith(OWNER_TYPE, { podId: id, webId, visible: true });
|
||||
expect(storage.setField).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('updates the existing object if there already is an owner with this WebID.', async(): Promise<void> => {
|
||||
storage.find.mockResolvedValueOnce([{ id: 'id1', webId, visible: false }]);
|
||||
await expect(store.updateOwner(id, webId, true)).resolves.toBeUndefined();
|
||||
expect(storage.find).toHaveBeenCalledTimes(1);
|
||||
expect(storage.find).toHaveBeenLastCalledWith(OWNER_TYPE, { podId: id, webId });
|
||||
expect(storage.create).toHaveBeenCalledTimes(0);
|
||||
expect(storage.setField).toHaveBeenCalledTimes(1);
|
||||
expect(storage.setField).toHaveBeenLastCalledWith(OWNER_TYPE, 'id1', 'visible', true);
|
||||
});
|
||||
|
||||
it('can remove an owner.', async(): Promise<void> => {
|
||||
storage.find.mockResolvedValueOnce([{ id: 'id1', webId, visible: false },
|
||||
{ id: 'id2', webId: 'otherWebId', visible: false }]);
|
||||
await expect(store.removeOwner(id, webId)).resolves.toBeUndefined();
|
||||
expect(storage.find).toHaveBeenCalledTimes(1);
|
||||
expect(storage.find).toHaveBeenLastCalledWith(OWNER_TYPE, { podId: id });
|
||||
expect(storage.delete).toHaveBeenCalledTimes(1);
|
||||
expect(storage.delete).toHaveBeenLastCalledWith(OWNER_TYPE, 'id1');
|
||||
});
|
||||
|
||||
it('does nothing if there is no matching owner.', async(): Promise<void> => {
|
||||
storage.find.mockResolvedValueOnce([{ id: 'id2', webId: 'otherWebId', visible: false }]);
|
||||
await expect(store.removeOwner(id, webId)).resolves.toBeUndefined();
|
||||
expect(storage.find).toHaveBeenCalledTimes(1);
|
||||
expect(storage.find).toHaveBeenLastCalledWith(OWNER_TYPE, { podId: id });
|
||||
expect(storage.delete).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('cannot remove the last owner.', async(): Promise<void> => {
|
||||
storage.find.mockResolvedValueOnce([{ id: 'id1', webId, visible: false }]);
|
||||
await expect(store.removeOwner(id, webId)).rejects.toThrow(BadRequestHttpError);
|
||||
expect(storage.find).toHaveBeenCalledTimes(1);
|
||||
expect(storage.find).toHaveBeenLastCalledWith(OWNER_TYPE, { podId: id });
|
||||
expect(storage.delete).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
@ -0,0 +1,99 @@
|
||||
import type { ServerResponse } from 'http';
|
||||
import { createResponse } from 'node-mocks-http';
|
||||
import { RepresentationMetadata } from '../../../../../../src/http/representation/RepresentationMetadata';
|
||||
import { OwnerMetadataWriter } from '../../../../../../src/identity/interaction/pod/util/OwnerMetadataWriter';
|
||||
import { PodStore } from '../../../../../../src/identity/interaction/pod/util/PodStore';
|
||||
import type { StorageLocationStrategy } from '../../../../../../src/server/description/StorageLocationStrategy';
|
||||
import { joinUrl } from '../../../../../../src/util/PathUtil';
|
||||
|
||||
describe('An OwnerMetadataWriter', (): void => {
|
||||
const id = 'id';
|
||||
const accountId = 'accountId';
|
||||
const target = { path: 'http://example.com/pod/' };
|
||||
const webId = 'http://example.com/webId#me';
|
||||
let metadata: RepresentationMetadata;
|
||||
let response: ServerResponse;
|
||||
let podStore: jest.Mocked<PodStore>;
|
||||
let storageStrategy: jest.Mocked<StorageLocationStrategy>;
|
||||
let writer: OwnerMetadataWriter;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
metadata = new RepresentationMetadata(target);
|
||||
|
||||
response = createResponse();
|
||||
|
||||
podStore = {
|
||||
findByBaseUrl: jest.fn().mockResolvedValue({ id, accountId }),
|
||||
getOwners: jest.fn().mockResolvedValue([{ webId, visible: true }]),
|
||||
} satisfies Partial<PodStore> as any;
|
||||
|
||||
storageStrategy = {
|
||||
getStorageIdentifier: jest.fn().mockResolvedValue(target),
|
||||
};
|
||||
|
||||
writer = new OwnerMetadataWriter(podStore, storageStrategy);
|
||||
});
|
||||
|
||||
it('adds the correct link headers.', async(): Promise<void> => {
|
||||
await expect(writer.handle({ metadata, response })).resolves.toBeUndefined();
|
||||
expect(response.getHeaders()).toEqual({ link: `<${webId}>; rel="http://www.w3.org/ns/solid/terms#owner"` });
|
||||
expect(storageStrategy.getStorageIdentifier).toHaveBeenCalledTimes(1);
|
||||
expect(storageStrategy.getStorageIdentifier).toHaveBeenLastCalledWith(target);
|
||||
expect(podStore.findByBaseUrl).toHaveBeenCalledTimes(1);
|
||||
expect(podStore.findByBaseUrl).toHaveBeenLastCalledWith(target.path);
|
||||
expect(podStore.getOwners).toHaveBeenCalledTimes(1);
|
||||
expect(podStore.getOwners).toHaveBeenLastCalledWith(id);
|
||||
});
|
||||
|
||||
it('adds no headers if the identifier is a blank node.', async(): Promise<void> => {
|
||||
metadata = new RepresentationMetadata();
|
||||
await expect(writer.handle({ metadata, response })).resolves.toBeUndefined();
|
||||
expect(response.getHeaders()).toEqual({});
|
||||
expect(storageStrategy.getStorageIdentifier).toHaveBeenCalledTimes(0);
|
||||
expect(podStore.findByBaseUrl).toHaveBeenCalledTimes(0);
|
||||
expect(podStore.getOwners).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('adds no headers if no root storage could be found.', async(): Promise<void> => {
|
||||
storageStrategy.getStorageIdentifier.mockRejectedValueOnce(new Error('bad identifier'));
|
||||
await expect(writer.handle({ metadata, response })).resolves.toBeUndefined();
|
||||
expect(response.getHeaders()).toEqual({});
|
||||
expect(storageStrategy.getStorageIdentifier).toHaveBeenCalledTimes(1);
|
||||
expect(storageStrategy.getStorageIdentifier).toHaveBeenLastCalledWith(target);
|
||||
expect(podStore.findByBaseUrl).toHaveBeenCalledTimes(0);
|
||||
expect(podStore.getOwners).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('adds no headers if the target is not a pod base URL.', async(): Promise<void> => {
|
||||
metadata = new RepresentationMetadata({ path: joinUrl(target.path, 'document') });
|
||||
await expect(writer.handle({ metadata, response })).resolves.toBeUndefined();
|
||||
expect(response.getHeaders()).toEqual({});
|
||||
expect(storageStrategy.getStorageIdentifier).toHaveBeenCalledTimes(1);
|
||||
expect(storageStrategy.getStorageIdentifier).toHaveBeenLastCalledWith({ path: joinUrl(target.path, 'document') });
|
||||
expect(podStore.findByBaseUrl).toHaveBeenCalledTimes(0);
|
||||
expect(podStore.getOwners).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('adds no headers if there is no matching pod object.', async(): Promise<void> => {
|
||||
podStore.findByBaseUrl.mockResolvedValueOnce(undefined);
|
||||
await expect(writer.handle({ metadata, response })).resolves.toBeUndefined();
|
||||
expect(response.getHeaders()).toEqual({});
|
||||
expect(storageStrategy.getStorageIdentifier).toHaveBeenCalledTimes(1);
|
||||
expect(storageStrategy.getStorageIdentifier).toHaveBeenLastCalledWith(target);
|
||||
expect(podStore.findByBaseUrl).toHaveBeenCalledTimes(1);
|
||||
expect(podStore.findByBaseUrl).toHaveBeenLastCalledWith(target.path);
|
||||
expect(podStore.getOwners).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('adds no headers if there are no matching owners.', async(): Promise<void> => {
|
||||
podStore.getOwners.mockResolvedValueOnce(undefined);
|
||||
await expect(writer.handle({ metadata, response })).resolves.toBeUndefined();
|
||||
expect(response.getHeaders()).toEqual({});
|
||||
expect(storageStrategy.getStorageIdentifier).toHaveBeenCalledTimes(1);
|
||||
expect(storageStrategy.getStorageIdentifier).toHaveBeenLastCalledWith(target);
|
||||
expect(podStore.findByBaseUrl).toHaveBeenCalledTimes(1);
|
||||
expect(podStore.findByBaseUrl).toHaveBeenLastCalledWith(target.path);
|
||||
expect(podStore.getOwners).toHaveBeenCalledTimes(1);
|
||||
expect(podStore.getOwners).toHaveBeenLastCalledWith(id);
|
||||
});
|
||||
});
|
@ -8,6 +8,7 @@ import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHt
|
||||
|
||||
describe('A LinkWebIdHandler', (): void => {
|
||||
const id = 'id';
|
||||
const podId = 'podId';
|
||||
const accountId = 'accountId';
|
||||
const webId = 'http://example.com/pod/profile/card#me';
|
||||
let json: unknown;
|
||||
@ -29,7 +30,7 @@ describe('A LinkWebIdHandler', (): void => {
|
||||
} satisfies Partial<OwnershipValidator> as any;
|
||||
|
||||
podStore = {
|
||||
findAccount: jest.fn().mockResolvedValue(accountId),
|
||||
findByBaseUrl: jest.fn().mockResolvedValue({ accountId, id: podId }),
|
||||
} satisfies Partial<PodStore> as any;
|
||||
|
||||
webIdStore = {
|
||||
@ -80,8 +81,8 @@ describe('A LinkWebIdHandler', (): void => {
|
||||
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(podStore.findByBaseUrl).toHaveBeenCalledTimes(1);
|
||||
expect(podStore.findByBaseUrl).toHaveBeenLastCalledWith(podUrl);
|
||||
expect(webIdStore.create).toHaveBeenCalledTimes(1);
|
||||
expect(webIdStore.create).toHaveBeenLastCalledWith(webId, accountId);
|
||||
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(0);
|
||||
@ -93,12 +94,12 @@ describe('A LinkWebIdHandler', (): void => {
|
||||
expect(webIdStore.isLinked).toHaveBeenCalledTimes(1);
|
||||
expect(webIdStore.isLinked).toHaveBeenLastCalledWith(webId, accountId);
|
||||
expect(storageStrategy.getStorageIdentifier).toHaveBeenCalledTimes(0);
|
||||
expect(podStore.findAccount).toHaveBeenCalledTimes(0);
|
||||
expect(podStore.findByBaseUrl).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<void> => {
|
||||
podStore.findAccount.mockResolvedValueOnce(undefined);
|
||||
podStore.findByBaseUrl.mockResolvedValueOnce(undefined);
|
||||
await expect(handler.handle({ accountId, json } as any)).resolves.toEqual({
|
||||
json: { resource, webId, oidcIssuer: baseUrl },
|
||||
});
|
||||
@ -106,8 +107,8 @@ describe('A LinkWebIdHandler', (): void => {
|
||||
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(podStore.findByBaseUrl).toHaveBeenCalledTimes(1);
|
||||
expect(podStore.findByBaseUrl).toHaveBeenLastCalledWith(podUrl);
|
||||
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
|
||||
expect(webIdStore.create).toHaveBeenCalledTimes(1);
|
||||
|
Loading…
x
Reference in New Issue
Block a user