feat: Create RootInitializer to set up root resources

This commit is contained in:
Joachim Van Herwegen 2021-07-19 14:08:46 +02:00
parent 00f086fa79
commit c2ad892020
20 changed files with 213 additions and 292 deletions

View File

@ -8,6 +8,4 @@ This is the entry point to the main server setup.
## Init
Contains a list of initializer that need to be run when starting the server.
For example, when acl authorization is used,
an initializer will be added that makes sure there is an acl file in the root.
* *default*: The default setup that makes sure the root container is marked as pim:storage.
* *default*: The default setup that makes sure the root container has the necessary resources.

View File

@ -2,7 +2,7 @@
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"import": [
"files-scs:config/app/init/base/init.json",
"files-scs:config/app/init/initializers/root-container.json"
"files-scs:config/app/init/initializers/root.json"
],
"@graph": [
{
@ -10,7 +10,7 @@
"@id": "urn:solid-server:default:ParallelInitializer",
"@type": "ParallelHandler",
"handlers": [
{ "@id": "urn:solid-server:default:RootContainerInitializer" }
{ "@id": "urn:solid-server:default:RootInitializer" }
]
}
]

View File

@ -1,12 +0,0 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Makes sure the root container exists and marks it as pim:Storage.",
"@id": "urn:solid-server:default:RootContainerInitializer",
"@type": "RootContainerInitializer",
"settings_store": { "@id": "urn:solid-server:default:ResourceStore" },
"settings_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
}
]
}

View File

@ -0,0 +1,18 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Makes sure the root container exists and contains the necessary resources.",
"@id": "urn:solid-server:default:RootInitializer",
"@type": "RootInitializer",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"store": { "@id": "urn:solid-server:default:ResourceStore" },
"generator": {
"@type": "TemplatedResourcesGenerator",
"templateFolder": "$PACKAGE_ROOT/templates/root",
"factory": { "@type": "ExtensionBasedMapperFactory" },
"templateEngine": { "@type": "HandlebarsTemplateEngine" }
}
}
]
}

View File

@ -8,7 +8,7 @@
"assets": [
{
"StaticAssetHandler:_assets_key": "/favicon.ico",
"StaticAssetHandler:_assets_value": "$PACKAGE_ROOT/templates/root/favicon.ico"
"StaticAssetHandler:_assets_value": "$PACKAGE_ROOT/templates/images/favicon.ico"
},
{
"StaticAssetHandler:_assets_key": "/.well_known/css/styles/",

View File

@ -17,20 +17,6 @@
},
{ "@id": "urn:solid-server:default:WebAclAuthorizer" }
]
},
{
"comment": "Add to the list of initializers.",
"@id": "urn:solid-server:default:ParallelInitializer",
"ParallelHandler:_handlers": [
{
"comment": "Makes sure there is a root acl document. This is necessary for acl authorization.",
"@type": "AclInitializer",
"settings_store": { "@id": "urn:solid-server:default:ResourceStore" },
"settings_aclStrategy": { "@id": "urn:solid-server:default:AclStrategy" },
"settings_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
}
]
}
]
}

View File

@ -66,13 +66,12 @@ export * from './init/final/Finalizable';
export * from './init/final/ParallelFinalizer';
// Init
export * from './init/AclInitializer';
export * from './init/App';
export * from './init/AppRunner';
export * from './init/ConfigPodInitializer';
export * from './init/Initializer';
export * from './init/LoggerInitializer';
export * from './init/RootContainerInitializer';
export * from './init/RootInitializer';
export * from './init/ServerInitializer';
// LDP/Authorization

View File

@ -1,57 +0,0 @@
import { createReadStream } from 'fs';
import type { AuxiliaryIdentifierStrategy } from '../ldp/auxiliary/AuxiliaryIdentifierStrategy';
import { BasicRepresentation } from '../ldp/representation/BasicRepresentation';
import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
import { getLoggerFor } from '../logging/LogUtil';
import type { ResourceStore } from '../storage/ResourceStore';
import { TEXT_TURTLE } from '../util/ContentTypes';
import { createErrorMessage } from '../util/errors/ErrorUtil';
import { InternalServerError } from '../util/errors/InternalServerError';
import { ensureTrailingSlash, joinFilePath } from '../util/PathUtil';
import { Initializer } from './Initializer';
const DEFAULT_ACL_PATH = joinFilePath(__dirname, '../../templates/root/.acl');
/**
* Ensures that a root ACL is present.
*/
export class AclInitializer extends Initializer {
protected readonly logger = getLoggerFor(this);
private readonly store: ResourceStore;
private readonly aclStrategy: AuxiliaryIdentifierStrategy;
private readonly root: ResourceIdentifier;
private readonly aclPath: string;
public constructor(settings: {
store: ResourceStore;
aclStrategy: AuxiliaryIdentifierStrategy;
baseUrl: string;
aclPath?: string;
}) {
super();
this.store = settings.store;
this.aclStrategy = settings.aclStrategy;
this.root = { path: ensureTrailingSlash(settings.baseUrl) };
this.aclPath = settings.aclPath ?? DEFAULT_ACL_PATH;
}
// Solid, §4.1: "The root container (pim:Storage) MUST have an ACL auxiliary resource directly associated to it.
// The associated ACL document MUST include an authorization policy with acl:Control access privilege."
// https://solid.github.io/specification/protocol#storage
public async handle(): Promise<void> {
const rootAcl = this.aclStrategy.getAuxiliaryIdentifier(this.root);
if (await this.store.resourceExists(rootAcl)) {
this.logger.debug(`Existing root ACL document found at ${rootAcl.path}`);
} else {
this.logger.debug(`Installing root ACL document at ${rootAcl.path}`);
const aclDocument = createReadStream(this.aclPath, 'utf8');
try {
await this.store.setRepresentation(rootAcl, new BasicRepresentation(aclDocument, rootAcl, TEXT_TURTLE));
} catch (error: unknown) {
const message = `Issue initializing the root ACL resource: ${createErrorMessage(error)}`;
this.logger.error(message);
throw new InternalServerError(message, { cause: error });
}
}
}
}

View File

@ -1,56 +0,0 @@
import { BasicRepresentation } from '../ldp/representation/BasicRepresentation';
import { RepresentationMetadata } from '../ldp/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
import { getLoggerFor } from '../logging/LogUtil';
import type { ResourceStore } from '../storage/ResourceStore';
import { TEXT_TURTLE } from '../util/ContentTypes';
import { ensureTrailingSlash } from '../util/PathUtil';
import { addResourceMetadata } from '../util/ResourceUtil';
import { PIM, RDF } from '../util/Vocabularies';
import { Initializer } from './Initializer';
/**
* Initializes ResourceStores by creating a root container if it didn't exist yet.
*
* Solid, §4.1: "When a server supports a data pod, it MUST provide one or more storages (pim:Storage)
* a space of URIs in which data can be accessed. A storage is the root container for all of its contained resources."
* https://solid.github.io/specification/protocol#storage
*/
export class RootContainerInitializer extends Initializer {
protected readonly logger = getLoggerFor(this);
private readonly store: ResourceStore;
private readonly baseId: ResourceIdentifier;
public constructor(settings: { store: ResourceStore; baseUrl: string }) {
super();
this.store = settings.store;
this.baseId = { path: ensureTrailingSlash(settings.baseUrl) };
}
public async handle(): Promise<void> {
this.logger.debug(`Checking for root container at ${this.baseId.path}`);
if (!await this.store.resourceExists(this.baseId)) {
await this.createRootContainer();
} else {
this.logger.debug(`Existing root container found at ${this.baseId.path}`);
}
}
/**
* Create a root container in a ResourceStore.
*/
protected async createRootContainer(): Promise<void> {
const metadata = new RepresentationMetadata(this.baseId, TEXT_TURTLE);
addResourceMetadata(metadata, true);
// Make sure the root container is a pim:Storage
// This prevents deletion of the root container as storage root containers can not be deleted
// Solid, §4.1: "Servers exposing the storage resource MUST advertise by including the HTTP Link header
// with rel="type" targeting http://www.w3.org/ns/pim/space#Storage when responding to storages request URI."
// https://solid.github.io/specification/protocol#storage
metadata.add(RDF.type, PIM.terms.Storage);
this.logger.debug(`Creating root container at ${this.baseId.path}`);
await this.store.setRepresentation(this.baseId, new BasicRepresentation([], metadata));
}
}

View File

@ -0,0 +1,73 @@
import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
import { getLoggerFor } from '../logging/LogUtil';
import type { ResourcesGenerator } from '../pods/generate/ResourcesGenerator';
import type { ResourceStore } from '../storage/ResourceStore';
import { createErrorMessage } from '../util/errors/ErrorUtil';
import { NotFoundHttpError } from '../util/errors/NotFoundHttpError';
import { ensureTrailingSlash } from '../util/PathUtil';
import { PIM, RDF } from '../util/Vocabularies';
import { Initializer } from './Initializer';
/**
* Initializer that sets up the root container.
* Will copy all the files and folders in the given path to the corresponding documents and containers.
* This will always happen when the server starts unless the following 2 conditions are both fulfilled:
* * The container already exists.
* * The container has metadata indicating it is a pim:Storage.
*
* It is important that the ResourcesGenerator generates a `<> a pim:Storage` triple for the root container:
* this prevents deletion of the root container as storage root containers can not be deleted.
* Solid, §4.1: "Servers exposing the storage resource MUST advertise by including the HTTP Link header
* with rel="type" targeting http://www.w3.org/ns/pim/space#Storage when responding to storages request URI."
* https://solid.github.io/specification/protocol#storage
*/
export class RootInitializer extends Initializer {
protected readonly logger = getLoggerFor(this);
private readonly store: ResourceStore;
private readonly baseId: ResourceIdentifier;
private readonly generator: ResourcesGenerator;
public constructor(baseUrl: string, store: ResourceStore, generator: ResourcesGenerator) {
super();
this.baseId = { path: ensureTrailingSlash(baseUrl) };
this.store = store;
this.generator = generator;
}
public async handle(): Promise<void> {
this.logger.debug(`Checking for valid root container at ${this.baseId.path}`);
if (!await this.rootContainerIsValid()) {
this.logger.info(`Root container not found; initializing it.`);
const resources = this.generator.generate(this.baseId, {});
let count = 0;
for await (const { identifier: resourceId, representation } of resources) {
try {
await this.store.setRepresentation(resourceId, representation);
count += 1;
} catch (error: unknown) {
this.logger.warn(`Failed to create resource ${resourceId.path}: ${createErrorMessage(error)}`);
}
}
this.logger.info(`Initialized root container with ${count} resources.`);
} else {
this.logger.debug(`Valid root container found at ${this.baseId.path}`);
}
}
/**
* Verifies if the root container already exists and has the pim:Storage type.
*/
private async rootContainerIsValid(): Promise<boolean> {
try {
const representation = await this.store.getRepresentation(this.baseId, {});
representation.data.destroy();
return representation.metadata.getAll(RDF.terms.type).some((term): boolean => term.equals(PIM.terms.Storage));
} catch (error: unknown) {
if (NotFoundHttpError.isInstance(error)) {
return false;
}
throw error;
}
}
}

View File

@ -1,6 +1,5 @@
import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
import type { ResourceStore } from '../../storage/ResourceStore';
import type { PodSettings } from '../settings/PodSettings';
import type { ResourcesGenerator } from './ResourcesGenerator';
/**
@ -12,7 +11,7 @@ import type { ResourcesGenerator } from './ResourcesGenerator';
*
* @returns The amount of resources that were added.
*/
export async function addGeneratedResources(identifier: ResourceIdentifier, settings: PodSettings,
export async function addGeneratedResources(identifier: ResourceIdentifier, settings: NodeJS.Dict<string>,
generator: ResourcesGenerator, store: ResourceStore): Promise<number> {
const resources = generator.generate(identifier, settings);
let count = 0;

View File

@ -9,6 +9,7 @@ import type {
FileIdentifierMapperFactory,
ResourceLink,
} from '../../storage/mapping/FileIdentifierMapper';
import { TEXT_TURTLE } from '../../util/ContentTypes';
import { guardStream } from '../../util/GuardedStream';
import type { Guarded } from '../../util/GuardedStream';
import { joinFilePath, isContainerIdentifier, resolveAssetPath } from '../../util/PathUtil';
@ -130,7 +131,10 @@ export class TemplatedResourcesGenerator implements ResourcesGenerator {
const metadata = new RepresentationMetadata(link.identifier);
// Read file if it is not a container
if (!isContainerIdentifier(link.identifier)) {
if (isContainerIdentifier(link.identifier)) {
// Containers need to be an RDF type
metadata.contentType = TEXT_TURTLE;
} else {
data = await this.processFile(link, options);
metadata.contentType = link.contentType;
}

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

7
templates/root/.meta Normal file
View File

@ -0,0 +1,7 @@
@prefix pim: <http://www.w3.org/ns/pim/space#>.
# It is imperative the root container is marked as a pim:Storage :
# Solid, §4.1: "Servers exposing the storage resource MUST advertise by including the HTTP Link header
# with rel="type" targeting http://www.w3.org/ns/pim/space#Storage when responding to storages request URI."
# https://solid.github.io/specification/protocol#storage
<> a pim:Storage.

View File

@ -58,12 +58,10 @@ describe.each(stores)('An LDP handler allowing all requests %s', (name, { storeC
await app.stop();
});
it('can read a container listing.', async(): Promise<void> => {
const response = await getResource(baseUrl);
it('can read the root container index page.', async(): Promise<void> => {
const response = await getResource(baseUrl, { contentType: 'text/html' });
await expectQuads(response, [
quad(namedNode(baseUrl), RDF.terms.type, LDP.terms.Container),
]);
await expect(response.text()).resolves.toContain('Welcome to the Community Solid Server');
// This is only here because we're accessing the root container
expect(response.headers.get('link')).toContain(`<${PIM.Storage}>; rel="type"`);
@ -71,7 +69,7 @@ describe.each(stores)('An LDP handler allowing all requests %s', (name, { storeC
it('can read a container listing with a query string.', async(): Promise<void> => {
// Helper functions would fail due to query params
const response = await fetch(`${baseUrl}?abc=def&xyz`);
const response = await fetch(`${baseUrl}?abc=def&xyz`, { headers: { accept: 'text/turtle' }});
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/turtle');
expect(response.headers.get('link')).toContain(`<${LDP.Container}>; rel="type"`);
@ -124,7 +122,12 @@ describe.each(stores)('An LDP handler allowing all requests %s', (name, { storeC
await putResource(containerUrl, { contentType: 'text/turtle' });
// GET
await getResource(containerUrl);
const response = await getResource(containerUrl);
// Verify container listing
await expectQuads(response, [
quad(namedNode(containerUrl), RDF.terms.type, LDP.terms.Container),
]);
// DELETE
expect(await deleteResource(containerUrl)).toBeUndefined();

View File

@ -1,12 +1,12 @@
import { RootContainerInitializer } from '../../src/init/RootContainerInitializer';
import { RoutingAuxiliaryStrategy } from '../../src/ldp/auxiliary/RoutingAuxiliaryStrategy';
import { BasicRepresentation } from '../../src/ldp/representation/BasicRepresentation';
import type { Representation } from '../../src/ldp/representation/Representation';
import { RepresentationMetadata } from '../../src/ldp/representation/RepresentationMetadata';
import { InMemoryDataAccessor } from '../../src/storage/accessors/InMemoryDataAccessor';
import { DataAccessorBasedStore } from '../../src/storage/DataAccessorBasedStore';
import { LockingResourceStore } from '../../src/storage/LockingResourceStore';
import type { ResourceStore } from '../../src/storage/ResourceStore';
import { APPLICATION_OCTET_STREAM } from '../../src/util/ContentTypes';
import { APPLICATION_OCTET_STREAM, TEXT_TURTLE } from '../../src/util/ContentTypes';
import { InternalServerError } from '../../src/util/errors/InternalServerError';
import { SingleRootIdentifierStrategy } from '../../src/util/identifiers/SingleRootIdentifierStrategy';
import { EqualReadWriteLocker } from '../../src/util/locking/EqualReadWriteLocker';
@ -15,6 +15,7 @@ import type { ReadWriteLocker } from '../../src/util/locking/ReadWriteLocker';
import { SingleThreadedResourceLocker } from '../../src/util/locking/SingleThreadedResourceLocker';
import { WrappedExpiringReadWriteLocker } from '../../src/util/locking/WrappedExpiringReadWriteLocker';
import { guardedStreamFrom } from '../../src/util/StreamUtil';
import { PIM, RDF } from '../../src/util/Vocabularies';
jest.useFakeTimers('legacy');
describe('A LockingResourceStore', (): void => {
@ -41,8 +42,9 @@ describe('A LockingResourceStore', (): void => {
);
// Initialize store
const initializer = new RootContainerInitializer({ store: source, baseUrl: base });
await initializer.handleSafe();
const metadata = new RepresentationMetadata({ path: base }, TEXT_TURTLE);
metadata.add(RDF.type, PIM.terms.Storage);
await source.setRepresentation({ path: base }, new BasicRepresentation([], metadata));
locker = new EqualReadWriteLocker(new SingleThreadedResourceLocker());
expiringLocker = new WrappedExpiringReadWriteLocker(locker, 1000);

View File

@ -1,83 +0,0 @@
import fs from 'fs';
import { AclInitializer } from '../../../src/init/AclInitializer';
import type { AuxiliaryIdentifierStrategy } from '../../../src/ldp/auxiliary/AuxiliaryIdentifierStrategy';
import { BasicRepresentation } from '../../../src/ldp/representation/BasicRepresentation';
import type { ResourceStore } from '../../../src/storage/ResourceStore';
import { InternalServerError } from '../../../src/util/errors/InternalServerError';
import { joinFilePath } from '../../../src/util/PathUtil';
const createReadStream = jest.spyOn(fs, 'createReadStream').mockReturnValue('file contents' as any);
jest.mock('../../../src/ldp/representation/BasicRepresentation');
// eslint-disable-next-line @typescript-eslint/naming-convention
const RepresentationMock: jest.Mock<BasicRepresentation> = BasicRepresentation as any;
describe('AclInitializer', (): void => {
const store: jest.Mocked<ResourceStore> = {
setRepresentation: jest.fn(),
resourceExists: jest.fn().mockImplementation((): any => false),
} as any;
const aclIdentifier = { path: 'http://test.com/.acl' };
const aclStrategy: jest.Mocked<AuxiliaryIdentifierStrategy> = {
getAuxiliaryIdentifier: jest.fn().mockReturnValue(aclIdentifier),
} as any;
const baseUrl = 'http://localhost:3000/';
afterEach((): void => {
jest.clearAllMocks();
});
it('sets the default ACL when none exists already.', async(): Promise<void> => {
const initializer = new AclInitializer({ baseUrl, store, aclStrategy });
await initializer.handle();
expect(aclStrategy.getAuxiliaryIdentifier).toHaveBeenCalledWith({ path: baseUrl });
expect(store.resourceExists).toHaveBeenCalledTimes(1);
expect(store.resourceExists).toHaveBeenCalledWith(aclIdentifier);
expect(store.setRepresentation).toHaveBeenCalledTimes(1);
expect(store.setRepresentation).toHaveBeenCalledWith(
{ path: 'http://test.com/.acl' }, RepresentationMock.mock.instances[0],
);
expect(createReadStream).toHaveBeenCalledTimes(1);
expect(createReadStream).toHaveBeenCalledWith(joinFilePath(__dirname, '../../../templates/root/.acl'), 'utf8');
expect(RepresentationMock).toHaveBeenCalledWith('file contents', aclIdentifier, 'text/turtle');
});
it('sets the specific ACL when one was specified.', async(): Promise<void> => {
const initializer = new AclInitializer({ baseUrl, store, aclStrategy, aclPath: '/path/doc.acl' });
await initializer.handle();
expect(aclStrategy.getAuxiliaryIdentifier).toHaveBeenCalledWith({ path: baseUrl });
expect(store.resourceExists).toHaveBeenCalledTimes(1);
expect(store.resourceExists).toHaveBeenCalledWith(aclIdentifier);
expect(store.setRepresentation).toHaveBeenCalledTimes(1);
expect(store.setRepresentation).toHaveBeenCalledWith(
{ path: 'http://test.com/.acl' }, RepresentationMock.mock.instances[0],
);
expect(createReadStream).toHaveBeenCalledTimes(1);
expect(createReadStream).toHaveBeenCalledWith('/path/doc.acl', 'utf8');
expect(RepresentationMock).toHaveBeenCalledWith('file contents', aclIdentifier, 'text/turtle');
});
it('does not invoke ACL initialization when a root ACL already exists.', async(): Promise<void> => {
store.resourceExists.mockResolvedValueOnce(true);
const initializer = new AclInitializer({ baseUrl, store, aclStrategy });
await initializer.handle();
expect(aclStrategy.getAuxiliaryIdentifier).toHaveBeenCalledWith({ path: baseUrl });
expect(store.resourceExists).toHaveBeenCalledTimes(1);
expect(store.resourceExists).toHaveBeenCalledWith(aclIdentifier);
expect(store.setRepresentation).toHaveBeenCalledTimes(0);
});
it('errors when the root ACL check errors.', async(): Promise<void> => {
store.setRepresentation.mockRejectedValueOnce(new Error('Fatal'));
const initializer = new AclInitializer({ baseUrl, store, aclStrategy });
const prom = initializer.handle();
await expect(prom).rejects.toThrow('Issue initializing the root ACL resource: Fatal');
await expect(prom).rejects.toThrow(InternalServerError);
});
});

View File

@ -1,44 +0,0 @@
import { RootContainerInitializer } from '../../../src/init/RootContainerInitializer';
import type { ResourceStore } from '../../../src/storage/ResourceStore';
import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError';
describe('A RootContainerInitializer', (): void => {
const baseUrl = 'http://test.com/';
const store: jest.Mocked<ResourceStore> = {
getRepresentation: jest.fn().mockRejectedValue(new NotFoundHttpError()),
setRepresentation: jest.fn(),
resourceExists: jest.fn(),
} as any;
const initializer = new RootContainerInitializer({ store, baseUrl });
afterEach((): void => {
jest.clearAllMocks();
});
it('invokes ResourceStore initialization.', async(): Promise<void> => {
store.resourceExists.mockResolvedValueOnce(false);
await initializer.handle();
expect(store.resourceExists).toHaveBeenCalledTimes(1);
expect(store.resourceExists).toHaveBeenCalledWith({ path: baseUrl });
expect(store.setRepresentation).toHaveBeenCalledTimes(1);
});
it('does not invoke ResourceStore initialization when a root container already exists.', async(): Promise<void> => {
store.resourceExists.mockResolvedValueOnce(true);
store.getRepresentation.mockReturnValueOnce(Promise.resolve({
data: { destroy: jest.fn() },
} as any));
await initializer.handle();
expect(store.resourceExists).toHaveBeenCalledTimes(1);
expect(store.resourceExists).toHaveBeenCalledWith({ path: 'http://test.com/' });
expect(store.setRepresentation).toHaveBeenCalledTimes(0);
});
it('errors when the store errors writing the root container.', async(): Promise<void> => {
store.resourceExists.mockRejectedValueOnce(new Error('Fatal'));
await expect(initializer.handle()).rejects.toThrow('Fatal');
});
});

View File

@ -0,0 +1,82 @@
import { RootInitializer } from '../../../src/init/RootInitializer';
import { BasicRepresentation } from '../../../src/ldp/representation/BasicRepresentation';
import { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata';
import type { Logger } from '../../../src/logging/Logger';
import { getLoggerFor } from '../../../src/logging/LogUtil';
import type { Resource, ResourcesGenerator } from '../../../src/pods/generate/ResourcesGenerator';
import type { ResourceStore } from '../../../src/storage/ResourceStore';
import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError';
import { PIM, RDF } from '../../../src/util/Vocabularies';
jest.mock('../../../src/logging/LogUtil', (): any => {
const logger: Logger = { warn: jest.fn(), debug: jest.fn(), info: jest.fn() } as any;
return { getLoggerFor: (): Logger => logger };
});
describe('A RootInitializer', (): void => {
const baseUrl = 'http://test.com/foo/';
let store: jest.Mocked<ResourceStore>;
let generatorData: Resource[];
let generator: jest.Mocked<ResourcesGenerator>;
let initializer: RootInitializer;
let logger: jest.Mocked<Logger>;
beforeEach(async(): Promise<void> => {
store = {
getRepresentation: jest.fn().mockRejectedValue(new NotFoundHttpError()),
setRepresentation: jest.fn(),
} as any;
generatorData = [
{ identifier: { path: '/.acl' }, representation: '/.acl' as any },
{ identifier: { path: '/container/' }, representation: '/container/' as any },
];
generator = {
generate: jest.fn(async function* (): any {
yield* generatorData;
}),
} as any;
initializer = new RootInitializer(baseUrl, store, generator);
logger = getLoggerFor(initializer) as any;
jest.clearAllMocks();
});
it('does nothing is the root container already has pim:Storage metadata.', async(): Promise<void> => {
const metadata = new RepresentationMetadata({ path: baseUrl }, { [RDF.type]: PIM.terms.Storage });
store.getRepresentation.mockResolvedValueOnce(new BasicRepresentation('data', metadata));
await expect(initializer.handle()).resolves.toBeUndefined();
expect(generator.generate).toHaveBeenCalledTimes(0);
expect(store.setRepresentation).toHaveBeenCalledTimes(0);
});
it('writes new resources if the container does not exist yet.', async(): Promise<void> => {
await expect(initializer.handle()).resolves.toBeUndefined();
expect(generator.generate).toHaveBeenCalledTimes(1);
expect(store.setRepresentation).toHaveBeenCalledTimes(2);
});
it('writes new resources if the container is not a pim:Storage.', async(): Promise<void> => {
store.getRepresentation.mockResolvedValueOnce(new BasicRepresentation('data', 'text/string'));
await expect(initializer.handle()).resolves.toBeUndefined();
expect(generator.generate).toHaveBeenCalledTimes(1);
expect(store.setRepresentation).toHaveBeenCalledTimes(2);
});
it('throws an error if there is a problem accessing the root container.', async(): Promise<void> => {
store.getRepresentation.mockRejectedValueOnce(new Error('bad data'));
await expect(initializer.handle()).rejects.toThrow('bad data');
});
it('logs warnings if there was a problem creating a resource.', async(): Promise<void> => {
store.setRepresentation.mockRejectedValueOnce(new Error('bad input'));
await expect(initializer.handle()).resolves.toBeUndefined();
expect(generator.generate).toHaveBeenCalledTimes(1);
expect(store.setRepresentation).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenLastCalledWith('Failed to create resource /.acl: bad input');
});
});

View File

@ -111,16 +111,18 @@ describe('A TemplatedResourcesGenerator', (): void => {
{ path: `${location.path}container/` },
{ path: `${location.path}container/template` },
]);
// Root has the 1 raw metadata triple (with <> changed to its identifier)
// Root has the 1 raw metadata triple (with <> changed to its identifier) and content-type
const rootMetadata = result[0].representation.metadata;
expect(rootMetadata.identifier.value).toBe(location.path);
expect(rootMetadata.quads()).toHaveLength(1);
expect(rootMetadata.quads()).toHaveLength(2);
expect(rootMetadata.get('pre:has')?.value).toBe('metadata');
expect(rootMetadata.contentType).toBe('text/turtle');
// Container has no metadata triples
// Container has no metadata triples besides content-type
const contMetadata = result[1].representation.metadata;
expect(contMetadata.identifier.value).toBe(`${location.path}container/`);
expect(contMetadata.quads()).toHaveLength(0);
expect(contMetadata.quads()).toHaveLength(1);
expect(contMetadata.contentType).toBe('text/turtle');
// Document has the 1 raw metadata triple (with <> changed to its identifier) and content-type
const docMetadata = result[2].representation.metadata;