diff --git a/src/app.ts b/src/app.ts index f511176..b465544 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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'; @@ -71,6 +72,8 @@ const setupValidation = (fastify: FastifyInstance) => { }); }; +export type LicenseResolver = (opt: TransformerContext) => Promise; + export type Options = { prisma: PrismaClient; opensearch: Client; @@ -83,6 +86,7 @@ export type Options = { fileTransformers?: FileTransformer[]; fileHandler: FileHandler; roCrateHandler: RoCrateHandler; + resolveValidLicenses?: LicenseResolver; }; const app: FastifyPluginAsync = async (fastify, options) => { const { @@ -97,6 +101,7 @@ const app: FastifyPluginAsync = async (fastify, options) => { fileTransformers, fileHandler, roCrateHandler, + resolveValidLicenses, } = options; if (!prisma) { @@ -129,15 +134,16 @@ const app: FastifyPluginAsync = 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, diff --git a/src/routes/__snapshots__/entity.test.ts.snap b/src/routes/__snapshots__/entity.test.ts.snap index 102f8b4..a3ed747 100644 --- a/src/routes/__snapshots__/entity.test.ts.snap +++ b/src/routes/__snapshots__/entity.test.ts.snap @@ -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", + }, +} +`; diff --git a/src/routes/crate.test.ts b/src/routes/crate.test.ts index 344f459..aede70c 100644 --- a/src/routes/crate.test.ts +++ b/src/routes/crate.test.ts @@ -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'; @@ -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(); }); @@ -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.head).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(); + }); + }); +}); diff --git a/src/routes/crate.ts b/src/routes/crate.ts index 01493cc..ab39616 100644 --- a/src/routes/crate.ts +++ b/src/routes/crate.ts @@ -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({ @@ -13,11 +14,12 @@ const paramsSchema = z.object({ type CrateRouteOptions = { prisma: PrismaClient; + accessTransformer: AccessTransformer; roCrateHandler: RoCrateHandler; }; const crate: FastifyPluginAsync = async (fastify, opts) => { - const { prisma, roCrateHandler } = opts; + const { prisma, accessTransformer, roCrateHandler } = opts; fastify.withTypeProvider().head( '/entity/:id/rocrate', @@ -37,9 +39,17 @@ const crate: FastifyPluginAsync = 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)); } @@ -75,6 +85,15 @@ const crate: FastifyPluginAsync = 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) { diff --git a/src/routes/entities.test.ts b/src/routes/entities.test.ts index 7b189c7..4bac882 100644 --- a/src/routes/entities.test.ts +++ b/src/routes/entities.test.ts @@ -318,3 +318,64 @@ 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, + }); + }); + }); +}); diff --git a/src/routes/entities.ts b/src/routes/entities.ts index 25b32c4..a1ca58d 100644 --- a/src/routes/entities.ts +++ b/src/routes/entities.ts @@ -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({ @@ -27,10 +27,11 @@ type EntitiesRouteOptions = { prisma: PrismaClient; accessTransformer: AccessTransformer; entityTransformers?: EntityTransformer[]; + resolveValidLicenses?: (opt: TransformerContext) => Promise; }; const entities: FastifyPluginAsync = async (fastify, opts) => { - const { prisma, accessTransformer, entityTransformers = [] } = opts; + const { prisma, accessTransformer, entityTransformers = [], resolveValidLicenses } = opts; fastify.withTypeProvider().get( '/entities', { @@ -54,6 +55,12 @@ const entities: FastifyPluginAsync = async (fastify, opts) }; } + if (resolveValidLicenses) { + where.metadataLicenseId = { + in: (await resolveValidLicenses({ request, fastify })) || [], + }; + } + const [dbEntities, total] = await Promise.all([ prisma.entity.findMany({ where, diff --git a/src/routes/entity.test.ts b/src/routes/entity.test.ts index 0dea12d..d4e365f 100644 --- a/src/routes/entity.test.ts +++ b/src/routes/entity.test.ts @@ -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'; @@ -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(); + }); + }); +}); diff --git a/src/routes/entity.ts b/src/routes/entity.ts index 2ad7f38..a10b409 100644 --- a/src/routes/entity.ts +++ b/src/routes/entity.ts @@ -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(), @@ -49,6 +49,9 @@ const entity: FastifyPluginAsync = 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 }); diff --git a/src/routes/file.test.ts b/src/routes/file.test.ts index de41493..cffeb58 100644 --- a/src/routes/file.test.ts +++ b/src/routes/file.test.ts @@ -1,7 +1,14 @@ 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, + RestrictedFileAccessTransformer, +} from '../test/helpers/fastify.js'; +import { AllPublicFileAccessTransformer } from '../transformers/default.js'; import type { FileHandler, FileResult } from '../types/fileHandlers.js'; import type { StandardErrorResponse } from '../utils/errors.js'; import fileRoute from './file.js'; @@ -18,7 +25,11 @@ describe('File Route', () => { beforeEach(async () => { await fastifyBefore(); - await fastify.register(fileRoute, { prisma, fileHandler: mockFileHandler }); + await fastify.register(fileRoute, { + prisma, + fileAccessTransformer: AllPublicFileAccessTransformer, + fileHandler: mockFileHandler, + }); vi.clearAllMocks(); }); @@ -34,6 +45,17 @@ describe('File Route', () => { meta: { storagePath: '/data/files/test.wav' }, createdAt: new Date(), updatedAt: new Date(), + entity: { + memberOf: null, + }, + }; + + const mockFile2 = { + ...mockFile, + entity: { + memberOf: 'http://example.com/collection', + rootCollection: 'http://example.com/other-collection', + }, }; describe('GET /file/:id', () => { @@ -293,6 +315,29 @@ describe('File Route', () => { expect(body.error.code).toBe('NOT_FOUND'); expect(body.error.message).toBe('The requested file could not be retrieved'); }); + + it('should resolve memberOf and rootCollection references', async () => { + prisma.entity.findMany.mockResolvedValue([]); + prisma.file.findUnique.mockResolvedValue(mockFile2); + const mockStream = Readable.from(['file content']); + vi.mocked(createReadStream).mockReturnValue(mockStream as never); + vi.mocked(mockFileHandler.get).mockResolvedValue({ + type: 'file', + path: '/data/files/test.wav', + metadata: { contentType: 'audio/wav', contentLength: 12 }, + }); + + const response = await fastify.inject({ + method: 'GET', + url: `/file/${encodeURIComponent('http://example.com/file/test.wav')}`, + }); + + expect(response.statusCode).toBe(200); + expect(prisma.entity.findMany).toHaveBeenCalledWith({ + where: { id: { in: ['http://example.com/collection', 'http://example.com/other-collection'] } }, + select: { id: true, name: true }, + }); + }); }); describe('HEAD /file/:id', () => { @@ -369,6 +414,27 @@ describe('File Route', () => { expect(body.error.code).toBe('NOT_FOUND'); expect(body.error.message).toBe('The requested file metadata was not found'); }); + + it('should resolve memberOf and rootCollection references', async () => { + prisma.entity.findMany.mockResolvedValue([]); + prisma.file.findUnique.mockResolvedValue(mockFile2); + + vi.mocked(mockFileHandler.head).mockResolvedValue({ + contentType: 'audio/wav', + contentLength: 12, + }); + + const response = await fastify.inject({ + method: 'HEAD', + url: `/file/${encodeURIComponent('http://example.com/file/test.wav')}`, + }); + + expect(response.statusCode).toBe(200); + expect(prisma.entity.findMany).toHaveBeenCalledWith({ + where: { id: { in: ['http://example.com/collection', 'http://example.com/other-collection'] } }, + select: { id: true, name: true }, + }); + }); }); describe('GET /file/:id - exhaustiveness check', () => { @@ -393,3 +459,76 @@ describe('File Route', () => { }); }); }); + +describe('File Route Restricted', () => { + const mockFileHandler: FileHandler = { + get: vi.fn(), + head: vi.fn(), + }; + + beforeEach(async () => { + await fastifyBefore(); + await fastify.register(fileRoute, { + prisma, + fileAccessTransformer: RestrictedFileAccessTransformer, + fileHandler: mockFileHandler, + }); + vi.clearAllMocks(); + }); + + afterEach(async () => { + await fastifyAfter(); + }); + + const mockFile = { + id: 'http://example.com/file/test.wav', + filename: 'test.wav', + mediaType: 'audio/wav', + size: BigInt(1024), + meta: { storagePath: '/data/files/test.wav' }, + createdAt: new Date(), + updatedAt: new Date(), + entity: { + memberOf: null, + }, + }; + + describe('HEAD /file/:id', () => { + it('should return 403', async () => { + prisma.file.findUnique.mockResolvedValue(mockFile); + vi.mocked(mockFileHandler.get).mockResolvedValue({ + type: 'redirect', + url: 'https://storage.example.com/files/test.wav', + }); + + const response = await fastify.inject({ + method: 'HEAD', + url: `/file/${encodeURIComponent('http://example.com/file/test.wav')}`, + }); + const body = (await response.json()) as { error: { code: string; message: string } }; + + expect(response.statusCode).toBe(403); + expect(body.error.code).toBe('FORBIDDEN'); + expect(mockFileHandler.get).not.toHaveBeenCalled(); + }); + }); + + describe('GET /file/:id', () => { + it('should return 403', async () => { + prisma.file.findUnique.mockResolvedValue(mockFile); + vi.mocked(mockFileHandler.get).mockResolvedValue({ + type: 'redirect', + url: 'https://storage.example.com/files/test.wav', + }); + + const response = await fastify.inject({ + method: 'GET', + url: `/file/${encodeURIComponent('http://example.com/file/test.wav')}`, + }); + const body = (await response.json()) as { error: { code: string; message: string } }; + + expect(response.statusCode).toBe(403); + expect(body.error.code).toBe('FORBIDDEN'); + }); + }); +}); diff --git a/src/routes/file.ts b/src/routes/file.ts index dc0a1f1..f4a7ab9 100644 --- a/src/routes/file.ts +++ b/src/routes/file.ts @@ -3,8 +3,10 @@ import type { FastifyPluginAsync } from 'fastify'; import type { ZodTypeProvider } from 'fastify-type-provider-zod'; import { z } from 'zod/v4'; import type { PrismaClient } from '../generated/prisma/client.js'; +import { baseFileTransformer, resolveEntityReferences } from '../transformers/default.js'; import type { FileHandler, FileMetadata } from '../types/fileHandlers.js'; -import { createInternalError, createNotFoundError } from '../utils/errors.js'; +import type { FileAccessTransformer } from '../types/transformers.js'; +import { createForbiddenError, createInternalError, createNotFoundError } from '../utils/errors.js'; import { setFileHeaders } from '../utils/headers.js'; const paramsSchema = z.object({ @@ -19,11 +21,12 @@ const querySchema = z.object({ type FileRouteOptions = { prisma: PrismaClient; + fileAccessTransformer: FileAccessTransformer; fileHandler: FileHandler; }; const file: FastifyPluginAsync = async (fastify, opts) => { - const { prisma, fileHandler } = opts; + const { prisma, fileAccessTransformer, fileHandler } = opts; fastify.withTypeProvider().head( '/file/:id', @@ -38,12 +41,25 @@ const file: FastifyPluginAsync = async (fastify, opts) => { try { const file = await prisma.file.findUnique({ where: { id }, + include: { entity: true }, }); if (!file) { return reply.code(404).send(createNotFoundError('The requested file was not found', id)); } + const refMap = await resolveEntityReferences([{ ...file.entity }], prisma); + const entity = { + ...file.entity, + ...baseFileTransformer(file), + memberOf: file.entity.memberOf ? (refMap.get(file.entity.memberOf) ?? null) : null, + rootCollection: file.entity.rootCollection ? (refMap.get(file.entity.rootCollection) ?? null) : null, + }; + const authorisedFile = await fileAccessTransformer(entity, { request, fastify }); + if (!authorisedFile.access.content) { + return reply.code(403).send(createForbiddenError('Access to this resource is restricted', id)); + } + const metadata: FileMetadata | false = await fileHandler.head(file, { request, fastify }); if (!metadata) { @@ -76,12 +92,25 @@ const file: FastifyPluginAsync = async (fastify, opts) => { try { const file = await prisma.file.findUnique({ where: { id }, + include: { entity: true }, }); if (!file) { return reply.code(404).send(createNotFoundError('The requested file was not found', id)); } + const refMap = await resolveEntityReferences([{ ...file.entity }], prisma); + const entity = { + ...file.entity, + ...baseFileTransformer(file), + memberOf: file.entity.memberOf ? (refMap.get(file.entity.memberOf) ?? null) : null, + rootCollection: file.entity.rootCollection ? (refMap.get(file.entity.rootCollection) ?? null) : null, + }; + const authorisedFile = await fileAccessTransformer(entity, { request, fastify }); + if (!authorisedFile.access.content) { + return reply.code(403).send(createForbiddenError('Access to this resource is restricted', id)); + } + const result = await fileHandler.get(file, { request, fastify }); if (!result) { diff --git a/src/routes/files.test.ts b/src/routes/files.test.ts index 5b45b37..af3b40e 100644 --- a/src/routes/files.test.ts +++ b/src/routes/files.test.ts @@ -23,6 +23,10 @@ describe('Files Route', () => { meta: {}, createdAt: new Date('2025-01-01'), updatedAt: new Date('2025-01-01'), + entity: { + memberOf: null, + rootCollection: null, + }, }; const mockFile2 = { @@ -33,6 +37,10 @@ describe('Files Route', () => { meta: {}, createdAt: new Date('2025-01-02'), updatedAt: new Date('2025-01-02'), + entity: { + memberOf: null, + rootCollection: null, + }, }; describe('GET /files', () => { @@ -139,6 +147,36 @@ describe('Files Route', () => { ); }); + it('should resolve referenced memberOf and rootCollection values for files', async () => { + const mockFile3 = { + ...mockFile1, + entity: { + memberOf: 'http://example.com/collection/1', + rootCollection: 'http://example.com/collection/1', + }, + }; + prisma.entity.findMany.mockResolvedValue([]); + prisma.file.findMany.mockResolvedValue([mockFile3]); + prisma.file.count.mockResolvedValue(1); + + const response = await fastify.inject({ + method: 'GET', + url: '/files', + }); + + expect(response.statusCode).toBe(200); + expect(prisma.entity.findMany).toHaveBeenCalledWith({ + where: { id: { in: ['http://example.com/collection/1'] } }, + select: { id: true, name: true }, + }); + const body = JSON.parse(response.body) as { + total: number; + files: { memberOf: unknown; rootCollection: unknown }[]; + }; + expect(body.files[0].memberOf).toBeNull(); + expect(body.files[0].rootCollection).toBeNull(); + }); + it('should return empty list when no files found', async () => { prisma.file.findMany.mockResolvedValue([]); prisma.file.count.mockResolvedValue(0); @@ -276,3 +314,56 @@ describe('Files Route', () => { }); }); }); + +describe('Files 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(filesRoute, { + prisma, + fileAccessTransformer: AllPublicFileAccessTransformer, + // @ts-expect-error + resolveValidLicenses, + }); + }); + + afterEach(async () => { + await fastifyAfter(); + }); + + describe('GET /files', () => { + it('should filter by metadataLicenseId', async () => { + prisma.file.findMany.mockResolvedValue([]); + prisma.file.count.mockResolvedValue(0); + hasLicense = true; + const response = await fastify.inject({ + method: 'GET', + url: '/files', + }); + expect(response.statusCode).toBe(200); + expect(prisma.file.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { entity: { metadataLicenseId: { in: await resolveValidLicenses() } } }, + }), + ); + }); + it('should filter by metadataLicenseId, no license', async () => { + prisma.file.findMany.mockResolvedValue([]); + prisma.file.count.mockResolvedValue(0); + hasLicense = false; + const response = await fastify.inject({ + method: 'GET', + url: '/files', + }); + expect(response.statusCode).toBe(200); + expect(prisma.file.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { entity: { metadataLicenseId: { in: [] } } }, + }), + ); + }); + }); +}); diff --git a/src/routes/files.ts b/src/routes/files.ts index f524690..9bd1a24 100644 --- a/src/routes/files.ts +++ b/src/routes/files.ts @@ -2,8 +2,8 @@ import type { FastifyPluginAsync } from 'fastify'; import type { ZodTypeProvider } from 'fastify-type-provider-zod'; import { z } from 'zod/v4'; import type { PrismaClient } from '../generated/prisma/client.js'; -import { baseFileTransformer } from '../transformers/default.js'; -import type { FileAccessTransformer, FileTransformer } from '../types/transformers.js'; +import { baseFileTransformer, resolveEntityReferences } from '../transformers/default.js'; +import type { FileAccessTransformer, FileTransformer, TransformerContext } from '../types/transformers.js'; import { createInternalError } from '../utils/errors.js'; const querySchema = z.object({ @@ -18,10 +18,11 @@ type FilesRouteOptions = { prisma: PrismaClient; fileAccessTransformer: FileAccessTransformer; fileTransformers?: FileTransformer[]; + resolveValidLicenses?: (opt: TransformerContext) => Promise; }; const files: FastifyPluginAsync = async (fastify, opts) => { - const { prisma, fileAccessTransformer, fileTransformers = [] } = opts; + const { prisma, fileAccessTransformer, fileTransformers, resolveValidLicenses } = opts; fastify.withTypeProvider().get( '/files', @@ -40,6 +41,13 @@ const files: FastifyPluginAsync = async (fastify, opts) => { where.entity = { memberOf }; } + if (resolveValidLicenses) { + where.entity = where.entity || {}; + where.entity.metadataLicenseId = { + in: (await resolveValidLicenses({ request, fastify })) || [], + }; + } + const [dbFiles, total] = await Promise.all([ prisma.file.findMany({ where, @@ -48,18 +56,28 @@ const files: FastifyPluginAsync = async (fastify, opts) => { }, skip: offset, take: limit, + include: { entity: true }, }), prisma.file.count({ where }), ]); + const refMap = await resolveEntityReferences( + dbFiles.map((f) => f.entity), + prisma, + ); // Apply transformers to each entity: base -> access -> additional const filesWithAccess = await Promise.all( dbFiles.map(async (dbFile) => { - const standardFile = baseFileTransformer(dbFile); - const authorisedFile = await fileAccessTransformer(standardFile, { request, fastify }); + const entity = { + ...dbFile.entity, + ...baseFileTransformer(dbFile), + memberOf: dbFile.entity.memberOf ? (refMap.get(dbFile.entity.memberOf) ?? null) : null, + rootCollection: dbFile.entity.rootCollection ? (refMap.get(dbFile.entity.rootCollection) ?? null) : null, + }; + const authorisedFile = await fileAccessTransformer(entity, { request, fastify }); let result = authorisedFile; - for (const transformer of fileTransformers) { + for (const transformer of fileTransformers || []) { result = await transformer(result, { request, fastify }); } diff --git a/src/routes/search.test.ts b/src/routes/search.test.ts index c90cb3a..f267aeb 100644 --- a/src/routes/search.test.ts +++ b/src/routes/search.test.ts @@ -901,3 +901,99 @@ describe('Search Route', () => { }); }); }); + +describe('Search 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(searchRoute, { + prisma, + opensearch, + accessTransformer: AllPublicAccessTransformer, + // @ts-expect-error + resolveValidLicenses, + }); + }); + + afterEach(async () => { + await fastifyAfter(); + }); + + describe('POST /search', () => { + function testMockLicense(hasLicense_: boolean) { + hasLicense = hasLicense_; + return async () => { + const mockSearchResponse = { + body: { + took: 5, + hits: { + total: { value: 0 }, + hits: [], + }, + aggregations: {}, + }, + }; + + // @ts-expect-error TS is looking at the wrong function signature + opensearch.search.mockResolvedValue(mockSearchResponse); + prisma.entity.findMany.mockResolvedValue([]); + const response = await fastify.inject({ + method: 'POST', + url: '/search', + payload: { + query: 'test', + searchType: 'basic', + }, + }); + expect(response.statusCode).toBe(200); + expect(opensearch.search).toHaveBeenCalledWith({ + index: 'entities', + body: { + query: { + bool: { + must: [ + { + multi_match: { + fields: ['name^2', 'description'], + fuzziness: 'AUTO', + query: 'test', + type: 'best_fields', + zero_terms_query: 'all', + }, + }, + ], + filter: [ + { + terms: { + metadataLicenseId: hasLicense ? await resolveValidLicenses() : [], + }, + }, + ], + }, + }, + aggs: { + inLanguage: { terms: { field: 'inLanguage.keyword', size: 20 } }, + mediaType: { terms: { field: 'mediaType.keyword', size: 20 } }, + communicationMode: { terms: { field: 'communicationMode.keyword', size: 20 } }, + entityType: { terms: { field: 'entityType.keyword', size: 20 } }, + }, + highlight: { + fields: { + name: {}, + description: {}, + }, + }, + sort: undefined, + from: 0, + size: 100, + }, + }); + }; + } + it('should filter by metadataLicenseId', testMockLicense(true)); + it('should return nothing without any valid license', testMockLicense(false)); + }); +}); diff --git a/src/routes/search.ts b/src/routes/search.ts index 39c58de..02bc1bf 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -6,7 +6,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, createInvalidRequestError } from '../utils/errors.js'; import { OpensearchQueryBuilder, type QueryBuilderOptions } from '../utils/queryBuilder.js'; @@ -38,6 +38,7 @@ type SearchRouteOptions = { opensearch: Client; accessTransformer: AccessTransformer; entityTransformers?: EntityTransformer[]; + resolveValidLicenses?: (opt: TransformerContext) => Promise; queryBuilderClass?: typeof OpensearchQueryBuilder; queryBuilderOptions?: QueryBuilderOptions; }; @@ -48,6 +49,7 @@ const search: FastifyPluginAsync = async (fastify, opts) => opensearch, accessTransformer, entityTransformers = [], + resolveValidLicenses, queryBuilderClass = OpensearchQueryBuilder, queryBuilderOptions, } = opts; @@ -60,8 +62,12 @@ const search: FastifyPluginAsync = async (fastify, opts) => }, }, async (request, reply) => { - const { searchType, query, filters, boundingBox, geohashPrecision, limit, offset, sort, order } = request.body; - + const { searchType, query, boundingBox, geohashPrecision, limit, offset, sort, order } = request.body; + let filters = request.body.filters; + if (resolveValidLicenses) { + filters = filters || {}; + filters.metadataLicenseId = (await resolveValidLicenses({ request, fastify })) || []; + } try { const opensearchQuery: Search_Request = { index: 'entities', diff --git a/src/test/helpers/fastify.ts b/src/test/helpers/fastify.ts index 54ab362..9bb83e7 100644 --- a/src/test/helpers/fastify.ts +++ b/src/test/helpers/fastify.ts @@ -5,6 +5,7 @@ import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod import { mockDeep, mockReset } from 'vitest-mock-extended'; import type { PrismaClient } from '../../generated/prisma/client.js'; +import type { AccessTransformer, FileAccessTransformer } from '../../types/transformers.js'; export let fastify: FastifyInstance; export const prisma = mockDeep(); @@ -25,3 +26,23 @@ export const fastifyBefore = async () => { export const fastifyAfter = async () => { await fastify.close(); }; + +export const RestrictedAccessTransformer: AccessTransformer = (entity) => ({ + ...entity, + access: { + metadata: false, + content: false, + metadataAuthorizationUrl: 'https://example.com/metadata-auth', + contentAuthorizationUrl: 'https://example.com/content-auth', + }, +}); + +export const RestrictedFileAccessTransformer: FileAccessTransformer = (file) => ({ + ...file, + access: { + metadata: false, + content: false, + metadataAuthorizationUrl: 'https://example.com/metadata-auth', + contentAuthorizationUrl: 'https://example.com/content-auth', + }, +}); diff --git a/src/transformers/default.ts b/src/transformers/default.ts index 602f336..3a37572 100644 --- a/src/transformers/default.ts +++ b/src/transformers/default.ts @@ -78,12 +78,14 @@ export type StandardFile = { size: number; }; +export type FileEntity = StandardFile & StandardEntity; + /** * Authorised file - includes access information * This is the output of the file access transformer * File metadata is always accessible - only content access is controlled */ -export type AuthorisedFile = StandardFile & { +export type AuthorisedFile = FileEntity & { access: FileAccessInfo; }; @@ -166,7 +168,7 @@ export const baseFileTransformer = (file: File): StandardFile => ({ * }); * ``` */ -export const AllPublicFileAccessTransformer = (file: StandardFile): AuthorisedFile => ({ +export const AllPublicFileAccessTransformer = (file: FileEntity): AuthorisedFile => ({ ...file, access: { content: true, diff --git a/src/types/transformers.ts b/src/types/transformers.ts index 32570d2..5ea88a1 100644 --- a/src/types/transformers.ts +++ b/src/types/transformers.ts @@ -1,5 +1,5 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'; -import type { AuthorisedEntity, AuthorisedFile, StandardEntity, StandardFile } from '../transformers/default.js'; +import type { AuthorisedEntity, AuthorisedFile, FileEntity, StandardEntity } from '../transformers/default.js'; /** * Context provided to entity transformers @@ -36,7 +36,7 @@ export type EntityTransformer = ( * Only content access is controlled (access.content) */ export type FileAccessTransformer = ( - file: StandardFile, + file: FileEntity, context: TransformerContext, ) => Promise | AuthorisedFile; diff --git a/src/utils/errors.test.ts b/src/utils/errors.test.ts index d518722..c96a7ec 100644 --- a/src/utils/errors.test.ts +++ b/src/utils/errors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createInternalError, createNotFoundError } from './errors.js'; +import { createForbiddenError, createInternalError, createNotFoundError } from './errors.js'; describe('Error Utilities', () => { describe('createInternalError', () => { @@ -72,4 +72,19 @@ describe('Error Utilities', () => { }); }); }); + + describe('createForbiddenError', () => { + it('should create a forbidden error', () => { + const message = 'Access to this resource is restricted'; + const error = createForbiddenError(message); + + expect(error).toEqual({ + error: { + code: 'FORBIDDEN', + message, + details: undefined, + }, + }); + }); + }); }); diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 7ad982d..cf3d3c9 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -5,6 +5,7 @@ const ERROR_CODES = { INTERNAL_ERROR: 'INTERNAL_ERROR', INVALID_REQUEST: 'INVALID_REQUEST', INVALID_ENTITY_TYPE: 'INVALID_ENTITY_TYPE', + FORBIDDEN: 'FORBIDDEN', } as const; type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; @@ -47,3 +48,6 @@ export const createInvalidRequestError = (message: string): StandardErrorRespons export const createInternalError = (message = 'Internal server error'): StandardErrorResponse => { return createErrorResponse(ERROR_CODES.INTERNAL_ERROR, message); }; + +export const createForbiddenError = (message: string, entityId?: string): StandardErrorResponse => + createErrorResponse(ERROR_CODES.FORBIDDEN, message, entityId ? { entityId } : undefined);