Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
EntityTransformer,
FileAccessTransformer,
FileTransformer,
TransformerContext,
} from './types/transformers.js';
import { createValidationError } from './utils/errors.js';
import type { QueryBuilderOptions } from './utils/queryBuilder.js';
Expand Down Expand Up @@ -83,6 +84,7 @@ export type Options = {
fileTransformers?: FileTransformer[];
fileHandler: FileHandler;
roCrateHandler: RoCrateHandler;
resolveValidLicenses?: (opt: TransformerContext) => Promise<string[]>;
};
const app: FastifyPluginAsync<Options> = async (fastify, options) => {
const {
Expand All @@ -97,6 +99,7 @@ const app: FastifyPluginAsync<Options> = async (fastify, options) => {
fileTransformers,
fileHandler,
roCrateHandler,
resolveValidLicenses,
} = options;

if (!prisma) {
Expand Down Expand Up @@ -129,15 +132,16 @@ const app: FastifyPluginAsync<Options> = async (fastify, options) => {
}
setupValidation(fastify);

fastify.register(entities, { prisma, accessTransformer, entityTransformers });
fastify.register(entities, { prisma, accessTransformer, entityTransformers, resolveValidLicenses });
fastify.register(entity, { prisma, accessTransformer, entityTransformers });
fastify.register(files, { prisma, fileAccessTransformer, fileTransformers });
fastify.register(file, { prisma, fileHandler });
fastify.register(crate, { prisma, roCrateHandler });
fastify.register(files, { prisma, fileAccessTransformer, fileTransformers, resolveValidLicenses });
fastify.register(file, { prisma, fileAccessTransformer, fileHandler });
fastify.register(crate, { prisma, accessTransformer, roCrateHandler });
fastify.register(search, {
prisma,
opensearch,
accessTransformer,
resolveValidLicenses,
entityTransformers,
queryBuilderClass,
queryBuilderOptions,
Expand Down
12 changes: 12 additions & 0 deletions src/routes/__snapshots__/entity.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,15 @@ exports[`Entity Route > GET /entity/:id > should return null for memberOf/rootCo
"rootCollection": null,
}
`;

exports[`Entity Route Restricted > GET /entity/:id > should return 403 1`] = `
{
"error": {
"code": "FORBIDDEN",
"details": {
"entityId": "http://example.com/entity/123",
},
"message": "Access to this resource is restricted",
},
}
`;
75 changes: 73 additions & 2 deletions src/routes/crate.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { createReadStream } from 'node:fs';
import { Readable } from 'node:stream';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fastify, fastifyAfter, fastifyBefore, prisma } from '../test/helpers/fastify.js';
import { fastify, fastifyAfter, fastifyBefore, prisma, RestrictedAccessTransformer } from '../test/helpers/fastify.js';
import { AllPublicAccessTransformer } from '../transformers/default.js';
import type { FileResult, RoCrateHandler } from '../types/fileHandlers.js';
import type { StandardErrorResponse } from '../utils/errors.js';
import crateRoute from './crate.js';
Expand All @@ -18,7 +19,11 @@ describe('Crate Route', () => {

beforeEach(async () => {
await fastifyBefore();
await fastify.register(crateRoute, { prisma, roCrateHandler: mockRoCrateHandler });
await fastify.register(crateRoute, {
prisma,
accessTransformer: AllPublicAccessTransformer,
roCrateHandler: mockRoCrateHandler,
});
vi.clearAllMocks();
});

Expand Down Expand Up @@ -421,3 +426,69 @@ describe('Crate Route', () => {
});
});
});

describe('Crate Route Restricted', () => {
const mockRoCrateHandler: RoCrateHandler = {
get: vi.fn(),
head: vi.fn(),
};

beforeEach(async () => {
await fastifyBefore();
await fastify.register(crateRoute, {
prisma,
accessTransformer: RestrictedAccessTransformer,
roCrateHandler: mockRoCrateHandler,
});
});

afterEach(async () => {
await fastifyAfter();
});

const mockFileEntity = {
id: 'http://example.com/entity/file.wav',
name: 'test.wav',
description: 'A test file',
entityType: 'http://schema.org/MediaObject',
memberOf: 'http://example.com/collection',
rootCollection: 'http://example.com/collection',
metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/',
contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/',
createdAt: new Date(),
updatedAt: new Date(),
meta: {},
};

describe('HEAD /entity/:id', () => {
it('should return 403', async () => {
prisma.entity.findUnique.mockResolvedValue(mockFileEntity);

const response = await fastify.inject({
method: 'HEAD',
url: `/entity/${encodeURIComponent('http://example.com/entity/file.wav')}/rocrate`,
});
const body = JSON.parse(response.body) as { error: { code: string; message: string } };

expect(response.statusCode).toBe(403);
expect(body.error.code).toBe('FORBIDDEN');
expect(mockRoCrateHandler.get).not.toHaveBeenCalled();
});
});

describe('GET /entity/:id', () => {
it('should return 403', async () => {
prisma.entity.findUnique.mockResolvedValue(mockFileEntity);

const response = await fastify.inject({
method: 'GET',
url: `/entity/${encodeURIComponent('http://example.com/entity/file.wav')}/rocrate`,
});
const body = JSON.parse(response.body) as { error: { code: string; message: string } };

expect(response.statusCode).toBe(403);
expect(body.error.code).toBe('FORBIDDEN');
expect(mockRoCrateHandler.get).not.toHaveBeenCalled();
});
});
});
25 changes: 22 additions & 3 deletions src/routes/crate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import { z } from 'zod/v4';
import type { PrismaClient } from '../generated/prisma/client.js';
import type { FileMetadata, RoCrateHandler } from '../types/fileHandlers.js';
import { createInternalError, createNotFoundError } from '../utils/errors.js';
import type { AccessTransformer } from '../types/transformers.js';
import { createForbiddenError, createInternalError, createNotFoundError } from '../utils/errors.js';
import { setFileHeaders } from '../utils/headers.js';

const paramsSchema = z.object({
Expand All @@ -13,11 +14,12 @@ const paramsSchema = z.object({

type CrateRouteOptions = {
prisma: PrismaClient;
accessTransformer: AccessTransformer;
roCrateHandler: RoCrateHandler;
};

const crate: FastifyPluginAsync<CrateRouteOptions> = async (fastify, opts) => {
const { prisma, roCrateHandler } = opts;
const { prisma, accessTransformer, roCrateHandler } = opts;

fastify.withTypeProvider<ZodTypeProvider>().head(
'/entity/:id/rocrate',
Expand All @@ -37,9 +39,17 @@ const crate: FastifyPluginAsync<CrateRouteOptions> = async (fastify, opts) => {
if (!entity) {
return reply.code(404).send(createNotFoundError('The requested entity was not found', id));
}
const standardEntity = {
...entity,
memberOf: { id: entity.memberOf || '', name: '' },
rootCollection: { id: entity.rootCollection || '', name: '' },
};
const authorisedEntity = await accessTransformer(standardEntity, { request, fastify });
if (!authorisedEntity.access.metadata) {
return reply.code(403).send(createForbiddenError('Access to this resource is restricted'));
}

const metadata: FileMetadata | false = await roCrateHandler.head(entity, { request, fastify });

if (!metadata) {
return reply.code(404).send(createNotFoundError('The requested RO-Crate metadata was not found', id));
}
Expand Down Expand Up @@ -75,6 +85,15 @@ const crate: FastifyPluginAsync<CrateRouteOptions> = async (fastify, opts) => {
return reply.code(404).send(createNotFoundError('The requested entity was not found', id));
}

const standardEntity = {
...entity,
memberOf: { id: entity.memberOf || '', name: '' },
rootCollection: { id: entity.rootCollection || '', name: '' },
};
const authorisedEntity = await accessTransformer(standardEntity, { request, fastify });
if (!authorisedEntity.access.metadata)
return reply.code(403).send(createForbiddenError('Access to this resource is restricted'));

const result = await roCrateHandler.get(entity, { request, fastify });

if (!result) {
Expand Down
59 changes: 59 additions & 0 deletions src/routes/entities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,3 +318,62 @@ describe('Entities Route', () => {
});
});
});

describe('Entities Route with License Filtering', () => {
let hasLicense = true;
async function resolveValidLicenses() {
if (hasLicense) return ['https://creativecommons.org/licenses/by/4.0/'];
}
beforeEach(async () => {
await fastifyBefore();
await fastify.register(entitiesRoute, {
prisma,
accessTransformer: AllPublicAccessTransformer,
// @ts-expect-error
resolveValidLicenses,
});
});

afterEach(async () => {
await fastifyAfter();
});

describe('GET /entities', () => {
it('should filter by metadataLicenseId', async () => {
prisma.entity.findMany.mockResolvedValue([]);
prisma.entity.count.mockResolvedValue(0);
hasLicense = true;
const response = await fastify.inject({
method: 'GET',
url: '/entities',
});

expect(response.statusCode).toBe(200);
expect(prisma.entity.findMany).toHaveBeenCalledWith({
where: { metadataLicenseId: { in: await resolveValidLicenses() } },
include: { file: { select: { id: true } } },
orderBy: { id: 'asc' },
skip: 0,
take: 100,
});
});
it('should return nothing without any valid license', async () => {
prisma.entity.findMany.mockResolvedValue([]);
prisma.entity.count.mockResolvedValue(0);
hasLicense = false;
const response = await fastify.inject({
method: 'GET',
url: '/entities',
});

expect(response.statusCode).toBe(200);
expect(prisma.entity.findMany).toHaveBeenCalledWith({
where: { metadataLicenseId: { in: [] } },
include: { file: { select: { id: true } } },
orderBy: { id: 'asc' },
skip: 0,
take: 100,
});
});
});
});
11 changes: 9 additions & 2 deletions src/routes/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import { z } from 'zod/v4';
import type { PrismaClient } from '../generated/prisma/client.js';
import { baseEntityTransformer, resolveEntityReferences } from '../transformers/default.js';
import type { AccessTransformer, EntityTransformer } from '../types/transformers.js';
import type { AccessTransformer, EntityTransformer, TransformerContext } from '../types/transformers.js';
import { createInternalError } from '../utils/errors.js';

const querySchema = z.object({
Expand All @@ -27,10 +27,11 @@ type EntitiesRouteOptions = {
prisma: PrismaClient;
accessTransformer: AccessTransformer;
entityTransformers?: EntityTransformer[];
resolveValidLicenses?: (opt: TransformerContext) => Promise<string[]>;
};

const entities: FastifyPluginAsync<EntitiesRouteOptions> = async (fastify, opts) => {
const { prisma, accessTransformer, entityTransformers = [] } = opts;
const { prisma, accessTransformer, entityTransformers = [], resolveValidLicenses } = opts;
fastify.withTypeProvider<ZodTypeProvider>().get(
'/entities',
{
Expand All @@ -54,6 +55,12 @@ const entities: FastifyPluginAsync<EntitiesRouteOptions> = async (fastify, opts)
};
}

if (resolveValidLicenses) {
where.metadataLicenseId = {
in: (await resolveValidLicenses({ request, fastify })) || [],
};
}

const [dbEntities, total] = await Promise.all([
prisma.entity.findMany({
where,
Expand Down
42 changes: 41 additions & 1 deletion src/routes/entity.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { fastify, fastifyAfter, fastifyBefore, prisma } from '../test/helpers/fastify.js';
import { fastify, fastifyAfter, fastifyBefore, prisma, RestrictedAccessTransformer } from '../test/helpers/fastify.js';
import { AllPublicAccessTransformer } from '../transformers/default.js';
import type { StandardErrorResponse } from '../utils/errors.js';
import entityRoute from './entity.js';
Expand Down Expand Up @@ -158,3 +158,43 @@ describe('Entity Route', () => {
});
});
});

describe('Entity Route Restricted', () => {
beforeEach(async () => {
await fastifyBefore();
await fastify.register(entityRoute, { prisma, accessTransformer: RestrictedAccessTransformer });
});

afterEach(async () => {
await fastifyAfter();
});

describe('GET /entity/:id', () => {
it('should return 403', async () => {
const mockEntity = {
id: 'http://example.com/entity/123',
name: 'Test Entity',
description: 'A test entity',
entityType: 'http://schema.org/Person',
memberOf: null,
rootCollection: null,
metadataLicenseId: 'https://choosealicense.com/no-permission/',
contentLicenseId: 'https://choosealicense.com/no-permission/',
createdAt: new Date(),
updatedAt: new Date(),
meta: {},
};

prisma.entity.findUnique.mockResolvedValue(mockEntity);

const response = await fastify.inject({
method: 'GET',
url: `/entity/${encodeURIComponent('http://example.com/entity/123')}`,
});
const body = JSON.parse(response.body);

expect(response.statusCode).toBe(403);
expect(body).toMatchSnapshot();
});
});
});
4 changes: 3 additions & 1 deletion src/routes/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { z } from 'zod/v4';
import type { PrismaClient } from '../generated/prisma/client.js';
import { baseEntityTransformer, resolveEntityReferences } from '../transformers/default.js';
import type { AccessTransformer, EntityTransformer } from '../types/transformers.js';
import { createInternalError, createNotFoundError } from '../utils/errors.js';
import { createForbiddenError, createInternalError, createNotFoundError } from '../utils/errors.js';

const paramsSchema = z.object({
id: z.url(),
Expand Down Expand Up @@ -49,6 +49,8 @@ const entity: FastifyPluginAsync<EntityRouteOptions> = async (fastify, opts) =>
};
const authorisedEntity = await accessTransformer(standardEntity, { request, fastify });

if (!authorisedEntity.access.metadata)
return reply.code(403).send(createForbiddenError('Access to this resource is restricted', id));
let result = authorisedEntity;
for (const transformer of entityTransformers) {
result = await transformer(result, { request, fastify });
Expand Down
Loading
Loading