From 2d8d4d46a5557f4035f3832b44d60c73f5e2ce40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Emarianfoo=E2=80=9C?= <13335743+marianfoo@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:20:50 +0200 Subject: [PATCH 1/2] feat: allow scoped spreadsheet imports --- README.md | 18 ++ importer-service.js | 259 +++++++++++++----- tests/demo-project/tests/upload.test.js | 10 +- tests/fixtures/scoped-project/db/schema.cds | 11 + .../scoped-project/srv/cat-service.cds | 9 + tests/importer-service.test.js | 77 ++++++ tests/scoped-import.test.js | 37 +++ 7 files changed, 343 insertions(+), 78 deletions(-) create mode 100644 tests/fixtures/scoped-project/db/schema.cds create mode 100644 tests/fixtures/scoped-project/srv/cat-service.cds create mode 100644 tests/importer-service.test.js create mode 100644 tests/scoped-import.test.js diff --git a/README.md b/README.md index 425583a..aee02b9 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,24 @@ This is a plugin for the [CAP](https://cap.cloud.sap/) framework that allows you to import data from spreadsheets into your CAP project. +## Enable Import for Selected Entities + +By default, the importer keeps the existing behavior and allows uploads for all entities. To limit uploads to selected entities, annotate the allowed service entities with `@spreadsheetimporter.enabled` in your CDS model: + +```cds +using my.bookshop as my from '../db/schema'; +using from 'cds-spreadsheetimporter-plugin'; + +service CatalogService { + @spreadsheetimporter.enabled + entity Books as projection on my.Books; + + entity Authors as projection on my.Authors; +} +``` + +When at least one entity is annotated, the importer rejects uploads for every other entity with `403 SPREADSHEET_IMPORT_ENTITY_NOT_ENABLED`. Annotating a service projection also enables uploads for its underlying persistence entity, so existing URLs such as `/odata/v4/importer/Spreadsheet(entity='my.bookshop.Books')/content` keep working. + ## Release Process This project uses [release-it](https://github.com/release-it/release-it) to automate version management and package publishing. The release workflow is configured through GitHub Actions and can be triggered in two ways: diff --git a/importer-service.js b/importer-service.js index 4f85d72..995b295 100644 --- a/importer-service.js +++ b/importer-service.js @@ -2,32 +2,134 @@ const cds = global.cds || require('@sap/cds'); const XLSX = require('xlsx'); const SheetHandler = require('./utils/SheetHandler'); const Parser = require('./utils/Parser'); -const { PassThrough } = require('stream'); -module.exports = class ImporterService extends cds.ApplicationService { +const IMPORTER_ENABLED_ANNOTATION = '@spreadsheetimporter.enabled'; +const ENTITY_NOT_ENABLED_CODE = 'SPREADSHEET_IMPORT_ENTITY_NOT_ENABLED'; + +function getDefinitions() { + return cds.model?.definitions || cds.entities?.() || {}; +} + +function getProjectionSourceName(entity) { + const ref = + entity?.query?.SELECT?.from?.ref || entity?.projection?.from?.ref || []; + const sourceName = ref + .map((part) => (typeof part === 'string' ? part : part.id)) + .filter(Boolean) + .join('.'); + + return sourceName || undefined; +} + +function resolvePersistenceName(entity, definitions, seen = new Set(), fallbackName) { + if (!entity) return undefined; + + const entityName = entity.name || fallbackName; + if (entityName) { + if (seen.has(entityName)) return entityName; + seen.add(entityName); + } + + const sourceName = getProjectionSourceName(entity); + if (!sourceName || sourceName === entityName) return entityName; + + return ( + resolvePersistenceName(definitions[sourceName], definitions, seen, sourceName) || + sourceName + ); +} + +function getEnabledImportEntities(definitions = getDefinitions()) { + const enabledEntities = new Set(); + + for (const [entityName, entity] of Object.entries(definitions)) { + if (entity?.kind !== 'entity' || entity[IMPORTER_ENABLED_ANNOTATION] !== true) { + continue; + } + + enabledEntities.add(entity.name || entityName); + + const persistenceName = resolvePersistenceName( + entity, + definitions, + new Set(), + entityName + ); + if (persistenceName) enabledEntities.add(persistenceName); + } + + return enabledEntities; +} + +function resolveImportEntity(entityName, definitions = getDefinitions()) { + const requested = definitions[entityName]; + if (!requested) return undefined; + + const requestedName = requested.name || entityName; + const persistenceName = resolvePersistenceName( + requested, + definitions, + new Set(), + requestedName + ); + const target = definitions[persistenceName] || requested; + + return { + requested, + requestedName, + target, + targetName: target.name || persistenceName || requestedName, + }; +} + +function isImportEnabledForEntity(entityName, definitions = getDefinitions()) { + const enabledEntities = getEnabledImportEntities(definitions); + if (enabledEntities.size === 0) return true; + + const resolvedEntity = resolveImportEntity(entityName, definitions); + if (!resolvedEntity) return false; + + return ( + enabledEntities.has(entityName) || + enabledEntities.has(resolvedEntity.requestedName) || + enabledEntities.has(resolvedEntity.targetName) + ); +} + +class ImporterService extends cds.ApplicationService { init() { this.on('UPDATE', 'Spreadsheet', async (req) => { - try { - console.log( - 'Spreadsheet importer received request with content type:', - req.headers && req.headers['content-type'] - ); - console.log('Entity parameter:', req.params[0].entity); - console.log('Request data structure:', Object.keys(req.data || {})); + console.log( + 'Spreadsheet importer received request with content type:', + req.headers && req.headers['content-type'] + ); + + const requestedEntityName = req.params?.[0]?.entity; + console.log('Entity parameter:', requestedEntityName); + console.log('Request data structure:', Object.keys(req.data || {})); + + const definitions = getDefinitions(); + const resolvedEntity = resolveImportEntity(requestedEntityName, definitions); + if (!resolvedEntity) { + return req.reject(400, `Entity '${requestedEntityName}' not found`); + } - const entity = cds.entities()[req.params[0].entity]; - if (!entity) { - req.error(400, `Entity '${req.params[0].entity}' not found`); - return; - } + if (!isImportEnabledForEntity(requestedEntityName, definitions)) { + return req.reject({ + status: 403, + code: ENTITY_NOT_ENABLED_CODE, + message: `Spreadsheet import is not enabled for entity '${requestedEntityName}'`, + }); + } - // Handle file content using a streaming approach for large files - const chunks = []; + // Handle file content using a streaming approach for large files + const chunks = []; - // Check if we have access to the content as a stream - console.log('Processing content as a stream'); + // Check if we have access to the content as a stream + console.log('Processing content as a stream'); - // Collect all chunks before processing + // Collect all chunks before processing + try { await new Promise((resolve, reject) => { req.data.content.on('data', (chunk) => { console.log(`Received chunk of size: ${chunk.length} bytes`); @@ -44,74 +146,85 @@ module.exports = class ImporterService extends cds.ApplicationService { reject(err); }); }); + } catch (error) { + console.error('Spreadsheet import error:', error); + return req.reject(500, `Failed to process spreadsheet: ${error.message}`); + } - // Once we have all chunks, process the file - const totalBuffer = Buffer.concat(chunks); - console.log( - `Processing complete file of size: ${totalBuffer.length} bytes` - ); + // Once we have all chunks, process the file + const totalBuffer = Buffer.concat(chunks); + console.log( + `Processing complete file of size: ${totalBuffer.length} bytes` + ); - try { - const spreadSheet = XLSX.read(totalBuffer, { - type: 'buffer', - cellNF: true, - cellDates: true, - cellText: true, - cellFormula: true, - }); + try { + const spreadSheet = XLSX.read(totalBuffer, { + type: 'buffer', + cellNF: true, + cellDates: true, + cellText: true, + cellFormula: true, + }); + + let spreadsheetSheetsData = []; + let columnNames = []; - let spreadsheetSheetsData = []; - let columnNames = []; + console.log( + `Workbook contains ${spreadSheet.SheetNames.length} sheets` + ); + // Loop over the sheet names in the workbook + for (const sheetName of Object.keys(spreadSheet.Sheets)) { + console.log(`Processing sheet: ${sheetName}`); + let currSheetData = SheetHandler.sheet_to_json( + spreadSheet.Sheets[sheetName] + ); console.log( - `Workbook contains ${spreadSheet.SheetNames.length} sheets` + `Sheet ${sheetName} has ${currSheetData.length} rows of data` ); - // Loop over the sheet names in the workbook - for (const sheetName of Object.keys(spreadSheet.Sheets)) { - console.log(`Processing sheet: ${sheetName}`); - let currSheetData = SheetHandler.sheet_to_json( - spreadSheet.Sheets[sheetName] - ); - console.log( - `Sheet ${sheetName} has ${currSheetData.length} rows of data` - ); - - for (const dataVal of currSheetData) { - Object.keys(dataVal).forEach((key) => { - dataVal[key].sheetName = sheetName; - }); - } - - spreadsheetSheetsData = spreadsheetSheetsData.concat(currSheetData); - columnNames = columnNames.concat( - XLSX.utils.sheet_to_json(spreadSheet.Sheets[sheetName], { - header: 1, - })[0] - ); + for (const dataVal of currSheetData) { + Object.keys(dataVal).forEach((key) => { + dataVal[key].sheetName = sheetName; + }); } - console.log( - `Total data rows to process: ${spreadsheetSheetsData.length}` - ); - const data = Parser.parseSpreadsheetData( - spreadsheetSheetsData, - entity.elements + spreadsheetSheetsData = spreadsheetSheetsData.concat(currSheetData); + columnNames = columnNames.concat( + XLSX.utils.sheet_to_json(spreadSheet.Sheets[sheetName], { + header: 1, + })[0] ); - console.log(`Inserting ${data.length} rows into ${entity.name}`); - - await cds.db.run(INSERT(data).into(entity.name)); - console.log('Import completed successfully'); - } catch (xlsxError) { - console.error('Error processing Excel file:', xlsxError); - req.error(400, `Failed to parse spreadsheet: ${xlsxError.message}`); - return; } + + console.log( + `Total data rows to process: ${spreadsheetSheetsData.length}` + ); + const data = Parser.parseSpreadsheetData( + spreadsheetSheetsData, + resolvedEntity.requested.elements || resolvedEntity.target.elements + ); + console.log( + `Inserting ${data.length} rows into ${resolvedEntity.targetName}` + ); + + await cds.db.run(INSERT(data).into(resolvedEntity.targetName)); + console.log('Import completed successfully'); } catch (error) { - console.error('Spreadsheet import error:', error); - req.error(500, `Failed to process spreadsheet: ${error.message}`); + console.error('Error processing Excel file:', error); + return req.reject(400, `Failed to parse spreadsheet: ${error.message}`); } }); return super.init(); } +} + +module.exports = ImporterService; +module.exports._private = { + ENTITY_NOT_ENABLED_CODE, + IMPORTER_ENABLED_ANNOTATION, + getEnabledImportEntities, + isImportEnabledForEntity, + resolveImportEntity, + resolvePersistenceName, }; diff --git a/tests/demo-project/tests/upload.test.js b/tests/demo-project/tests/upload.test.js index 0ac185d..07d85d4 100644 --- a/tests/demo-project/tests/upload.test.js +++ b/tests/demo-project/tests/upload.test.js @@ -1,9 +1,9 @@ const fs = require('fs'); const path = require('path'); -const { execSync } = require('child_process'); +const cds = require('@sap/cds'); describe('Upload API Tests', () => { - const BASE_URL = 'http://localhost:4004'; + const cdsTest = cds.test('serve', 'all', '--in-memory?').in(path.join(__dirname, '..')); test('complete upload workflow', async () => { @@ -12,7 +12,7 @@ describe('Upload API Tests', () => { const fileBuffer = fs.readFileSync(filePath); const uploadResponse = await fetch( - `${BASE_URL}/odata/v4/importer/Spreadsheet(entity='my.bookshop.Books')/content`, + `${cdsTest.url}/odata/v4/importer/Spreadsheet(entity='my.bookshop.Books')/content`, { method: 'PUT', headers: { @@ -34,7 +34,7 @@ describe('Upload API Tests', () => { // 3. Verify imported data const verifyResponse = await fetch( - `${BASE_URL}/odata/v4/catalog/Books`, + `${cdsTest.url}/odata/v4/catalog/Books`, { method: 'GET' } @@ -45,4 +45,4 @@ describe('Upload API Tests', () => { expect(books).toBeDefined(); expect(Array.isArray(books.value)).toBeTruthy(); }); -}); \ No newline at end of file +}); diff --git a/tests/fixtures/scoped-project/db/schema.cds b/tests/fixtures/scoped-project/db/schema.cds new file mode 100644 index 0000000..0e89617 --- /dev/null +++ b/tests/fixtures/scoped-project/db/schema.cds @@ -0,0 +1,11 @@ +namespace scoped.bookshop; + +entity Books { + key ID : Integer; + title : String; +} + +entity Authors { + key ID : Integer; + name : String; +} diff --git a/tests/fixtures/scoped-project/srv/cat-service.cds b/tests/fixtures/scoped-project/srv/cat-service.cds new file mode 100644 index 0000000..1af5e9d --- /dev/null +++ b/tests/fixtures/scoped-project/srv/cat-service.cds @@ -0,0 +1,9 @@ +using scoped.bookshop as scoped from '../db/schema'; +using from 'cds-spreadsheetimporter-plugin'; + +service CatalogService { + @spreadsheetimporter.enabled + entity Books as projection on scoped.Books; + + entity Authors as projection on scoped.Authors; +} diff --git a/tests/importer-service.test.js b/tests/importer-service.test.js new file mode 100644 index 0000000..d4f4849 --- /dev/null +++ b/tests/importer-service.test.js @@ -0,0 +1,77 @@ +const { + _private: { + IMPORTER_ENABLED_ANNOTATION, + getEnabledImportEntities, + isImportEnabledForEntity, + resolveImportEntity, + }, +} = require('../importer-service'); + +function entity(name, extra = {}) { + return { + name, + kind: 'entity', + elements: {}, + ...extra, + }; +} + +function projection(name, sourceName, extra = {}) { + return entity(name, { + query: { + SELECT: { + from: { + ref: [sourceName], + }, + }, + }, + ...extra, + }); +} + +describe('import entity scoping', () => { + test('allows all entities when no entity is explicitly enabled', () => { + const definitions = { + 'my.bookshop.Books': entity('my.bookshop.Books'), + 'my.bookshop.Authors': entity('my.bookshop.Authors'), + }; + + expect(isImportEnabledForEntity('my.bookshop.Books', definitions)).toBe(true); + expect(isImportEnabledForEntity('my.bookshop.Authors', definitions)).toBe(true); + }); + + test('allows annotated projections and their persistence entities', () => { + const definitions = { + 'CatalogService.Books': projection('CatalogService.Books', 'my.bookshop.Books', { + [IMPORTER_ENABLED_ANNOTATION]: true, + }), + 'CatalogService.Authors': projection( + 'CatalogService.Authors', + 'my.bookshop.Authors' + ), + 'my.bookshop.Books': entity('my.bookshop.Books'), + 'my.bookshop.Authors': entity('my.bookshop.Authors'), + }; + + expect(Array.from(getEnabledImportEntities(definitions)).sort()).toEqual([ + 'CatalogService.Books', + 'my.bookshop.Books', + ]); + expect(isImportEnabledForEntity('CatalogService.Books', definitions)).toBe(true); + expect(isImportEnabledForEntity('my.bookshop.Books', definitions)).toBe(true); + expect(isImportEnabledForEntity('CatalogService.Authors', definitions)).toBe(false); + expect(isImportEnabledForEntity('my.bookshop.Authors', definitions)).toBe(false); + }); + + test('resolves projection uploads to the persistence entity', () => { + const definitions = { + 'CatalogService.Books': projection('CatalogService.Books', 'my.bookshop.Books'), + 'my.bookshop.Books': entity('my.bookshop.Books'), + }; + + const resolved = resolveImportEntity('CatalogService.Books', definitions); + + expect(resolved.requested.name).toBe('CatalogService.Books'); + expect(resolved.target.name).toBe('my.bookshop.Books'); + }); +}); diff --git a/tests/scoped-import.test.js b/tests/scoped-import.test.js new file mode 100644 index 0000000..ccf3e39 --- /dev/null +++ b/tests/scoped-import.test.js @@ -0,0 +1,37 @@ +const path = require('path'); +const cds = require('@sap/cds'); + +describe('scoped spreadsheet imports', () => { + const cdsTest = cds + .test('serve', 'all', '--in-memory?') + .in(path.join(__dirname, 'fixtures', 'scoped-project')); + + function upload(entity) { + return fetch( + `${cdsTest.url}/odata/v4/importer/Spreadsheet(entity='${entity}')/content`, + { + method: 'PUT', + headers: { + 'Content-Type': + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + body: Buffer.from('not an xlsx file'), + } + ); + } + + test('rejects entities that are not enabled for spreadsheet import', async () => { + const response = await upload('scoped.bookshop.Authors'); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error.code).toBe('SPREADSHEET_IMPORT_ENTITY_NOT_ENABLED'); + }); + + test('allows annotated projection targets to pass the scope check', async () => { + const response = await upload('scoped.bookshop.Books'); + + expect(response.status).not.toBe(403); + expect(response.ok).toBe(true); + }); +}); From 8b1006e15657491df4ee1ae3b18e60b63dde2ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Emarianfoo=E2=80=9C?= <13335743+marianfoo@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:38:48 +0200 Subject: [PATCH 2/2] test: cover cap entity lookup scoping --- README.md | 8 ++++- importer-service.js | 64 +++++++++++++++++++++++++++++----- tests/importer-service.test.js | 29 +++++++++++++++ tests/scoped-import.test.js | 27 +++++++++++--- 4 files changed, 114 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index aee02b9..e07aa24 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,13 @@ service CatalogService { } ``` -When at least one entity is annotated, the importer rejects uploads for every other entity with `403 SPREADSHEET_IMPORT_ENTITY_NOT_ENABLED`. Annotating a service projection also enables uploads for its underlying persistence entity, so existing URLs such as `/odata/v4/importer/Spreadsheet(entity='my.bookshop.Books')/content` keep working. +When at least one entity is annotated, the importer rejects uploads for every other entity with `403 SPREADSHEET_IMPORT_ENTITY_NOT_ENABLED`. Annotating a service projection also enables uploads for its underlying persistence entity, so existing URLs such as `/odata/v4/importer/Spreadsheet(entity='my.bookshop.Books')/content` keep working. Short entity names resolved by CAP, such as `Books`, are supported as well. + +Use this annotation only to control which entities the importer may target. For user or role-based authorization, continue to use CAP authorization annotations such as `@requires` or `@restrict` on the importer service or your application services. For example: + +```cds +annotate ImporterService with @requires: 'Admin'; +``` ## Release Process diff --git a/importer-service.js b/importer-service.js index 995b295..6c009c9 100644 --- a/importer-service.js +++ b/importer-service.js @@ -10,6 +10,27 @@ function getDefinitions() { return cds.model?.definitions || cds.entities?.() || {}; } +function getEntityLookup() { + return cds.entities?.() || {}; +} + +function findEntity(entityName, definitions, entityLookup) { + if (definitions[entityName]) { + return { + entity: definitions[entityName], + entityName, + }; + } + + const entity = entityLookup[entityName]; + if (!entity) return undefined; + + return { + entity, + entityName: entity.name || entityName, + }; +} + function getProjectionSourceName(entity) { const ref = entity?.query?.SELECT?.from?.ref || entity?.projection?.from?.ref || []; @@ -61,18 +82,23 @@ function getEnabledImportEntities(definitions = getDefinitions()) { return enabledEntities; } -function resolveImportEntity(entityName, definitions = getDefinitions()) { - const requested = definitions[entityName]; - if (!requested) return undefined; +function resolveImportEntity( + entityName, + definitions = getDefinitions(), + entityLookup = getEntityLookup() +) { + const found = findEntity(entityName, definitions, entityLookup); + if (!found) return undefined; - const requestedName = requested.name || entityName; + const requested = found.entity; + const requestedName = found.entityName; const persistenceName = resolvePersistenceName( requested, definitions, new Set(), requestedName ); - const target = definitions[persistenceName] || requested; + const target = definitions[persistenceName] || entityLookup[persistenceName] || requested; return { requested, @@ -82,11 +108,19 @@ function resolveImportEntity(entityName, definitions = getDefinitions()) { }; } -function isImportEnabledForEntity(entityName, definitions = getDefinitions()) { +function isImportEnabledForEntity( + entityName, + definitions = getDefinitions(), + entityLookup = getEntityLookup() +) { const enabledEntities = getEnabledImportEntities(definitions); if (enabledEntities.size === 0) return true; - const resolvedEntity = resolveImportEntity(entityName, definitions); + const resolvedEntity = resolveImportEntity( + entityName, + definitions, + entityLookup + ); if (!resolvedEntity) return false; return ( @@ -109,12 +143,23 @@ class ImporterService extends cds.ApplicationService { console.log('Request data structure:', Object.keys(req.data || {})); const definitions = getDefinitions(); - const resolvedEntity = resolveImportEntity(requestedEntityName, definitions); + const entityLookup = getEntityLookup(); + const resolvedEntity = resolveImportEntity( + requestedEntityName, + definitions, + entityLookup + ); if (!resolvedEntity) { return req.reject(400, `Entity '${requestedEntityName}' not found`); } - if (!isImportEnabledForEntity(requestedEntityName, definitions)) { + if ( + !isImportEnabledForEntity( + requestedEntityName, + definitions, + entityLookup + ) + ) { return req.reject({ status: 403, code: ENTITY_NOT_ENABLED_CODE, @@ -223,6 +268,7 @@ module.exports = ImporterService; module.exports._private = { ENTITY_NOT_ENABLED_CODE, IMPORTER_ENABLED_ANNOTATION, + findEntity, getEnabledImportEntities, isImportEnabledForEntity, resolveImportEntity, diff --git a/tests/importer-service.test.js b/tests/importer-service.test.js index d4f4849..73d1384 100644 --- a/tests/importer-service.test.js +++ b/tests/importer-service.test.js @@ -63,6 +63,35 @@ describe('import entity scoping', () => { expect(isImportEnabledForEntity('my.bookshop.Authors', definitions)).toBe(false); }); + test('allows projections when the persistence entity is annotated', () => { + const definitions = { + 'CatalogService.Books': projection('CatalogService.Books', 'my.bookshop.Books'), + 'my.bookshop.Books': entity('my.bookshop.Books', { + [IMPORTER_ENABLED_ANNOTATION]: true, + }), + }; + + expect(isImportEnabledForEntity('CatalogService.Books', definitions)).toBe(true); + expect(isImportEnabledForEntity('my.bookshop.Books', definitions)).toBe(true); + }); + + test('resolves short entity names from CAP entity lookup', () => { + const definitions = { + 'CatalogService.Books': projection('CatalogService.Books', 'my.bookshop.Books', { + [IMPORTER_ENABLED_ANNOTATION]: true, + }), + 'my.bookshop.Books': entity('my.bookshop.Books'), + }; + const entityLookup = { + Books: definitions['my.bookshop.Books'], + }; + + const resolved = resolveImportEntity('Books', definitions, entityLookup); + + expect(resolved.targetName).toBe('my.bookshop.Books'); + expect(isImportEnabledForEntity('Books', definitions, entityLookup)).toBe(true); + }); + test('resolves projection uploads to the persistence entity', () => { const definitions = { 'CatalogService.Books': projection('CatalogService.Books', 'my.bookshop.Books'), diff --git a/tests/scoped-import.test.js b/tests/scoped-import.test.js index ccf3e39..c9a973f 100644 --- a/tests/scoped-import.test.js +++ b/tests/scoped-import.test.js @@ -28,10 +28,29 @@ describe('scoped spreadsheet imports', () => { expect(payload.error.code).toBe('SPREADSHEET_IMPORT_ENTITY_NOT_ENABLED'); }); - test('allows annotated projection targets to pass the scope check', async () => { - const response = await upload('scoped.bookshop.Books'); + test.each(['scoped.bookshop.Books', 'CatalogService.Books', 'Books'])( + 'allows enabled entity %s to pass the scope check', + async (entity) => { + const response = await upload(entity); - expect(response.status).not.toBe(403); - expect(response.ok).toBe(true); + expect(response.status).not.toBe(403); + expect(response.ok).toBe(true); + } + ); + + test('rejects short names for entities that are not enabled', async () => { + const response = await upload('Authors'); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error.code).toBe('SPREADSHEET_IMPORT_ENTITY_NOT_ENABLED'); + }); + + test('returns not found for unknown entities before parsing content', async () => { + const response = await upload('MissingEntity'); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error.message).toContain("Entity 'MissingEntity' not found"); }); });