diff --git a/backend/src/common/data-injection.tokens.ts b/backend/src/common/data-injection.tokens.ts index 0df3afffc..d25c50222 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -177,6 +177,9 @@ export enum UseCaseType { AGENTS_GET_AI_SAMPLE_ROWS = 'AGENTS_GET_AI_SAMPLE_ROWS', AGENTS_SCAN_AND_CREATE_SETTINGS = 'AGENTS_SCAN_AND_CREATE_SETTINGS', AGENTS_GET_COMPANY_SUBSCRIPTION_INFO = 'AGENTS_GET_COMPANY_SUBSCRIPTION_INFO', + AGENTS_INSERT_AI_ROWS = 'AGENTS_INSERT_AI_ROWS', + AGENTS_UPDATE_AI_ROWS = 'AGENTS_UPDATE_AI_ROWS', + AGENTS_DELETE_AI_ROWS = 'AGENTS_DELETE_AI_ROWS', SITENOVA_EXECUTE_RAW_QUERY = 'SITENOVA_EXECUTE_RAW_QUERY', SITENOVA_REGISTER_ENDUSER = 'SITENOVA_REGISTER_ENDUSER', diff --git a/backend/src/microservices/agents-microservice/agents.controller.ts b/backend/src/microservices/agents-microservice/agents.controller.ts index fe6708321..d1a59dc0a 100644 --- a/backend/src/microservices/agents-microservice/agents.controller.ts +++ b/backend/src/microservices/agents-microservice/agents.controller.ts @@ -12,6 +12,9 @@ import { AiConnectionContextRO, AiConnectionTablesRO, AiQueryResultRO, + AiRowsDeletedRO, + AiRowsInsertedRO, + AiRowsUpdatedRO, AiSampleRowsRO, CompanySubscriptionInfoRO, PermissionAllowedRO, @@ -21,16 +24,20 @@ import { } from './data-structures/agents-responses.ds.js'; import { AiDataRequestBaseDto, + DeleteAiRowsDto, ExecuteAiAggregationPipelineDto, ExecuteAiRawQueryDto, GetAiSampleRowsDto, GetAiTableStructureDto, + InsertAiRowsDto, + UpdateAiRowsDto, } from './dto/agents-ai-data.dtos.js'; import { ValidateConnectionEditDto, ValidateTableAiRequestDto, ValidateUserTokenDto } from './dto/agents-auth.dtos.js'; import { GetCompanySubscriptionInfoDto } from './dto/agents-company.dtos.js'; import { SetAgentsPublicPermissionsDto } from './dto/agents-public-permissions.dtos.js'; import { SetSiteRuntimePolicyDto } from './dto/agents-site-runtime-policy.dtos.js'; import { + IDeleteAiRows, IExecuteAiAggregationPipeline, IExecuteAiRawQuery, IGetAiConnectionContext, @@ -38,9 +45,11 @@ import { IGetAiSampleRows, IGetAiTableStructure, IGetCompanySubscriptionInfo, + IInsertAiRows, IScanAndCreateSettings, ISetPublicPermissions, ISetSiteRuntimePolicy, + IUpdateAiRows, IValidateConnectionEdit, IValidateTableAiRequest, IValidateUserToken, @@ -80,6 +89,12 @@ export class AgentsController { private readonly scanAndCreateSettingsUseCase: IScanAndCreateSettings, @Inject(UseCaseType.AGENTS_GET_COMPANY_SUBSCRIPTION_INFO) private readonly getCompanySubscriptionInfoUseCase: IGetCompanySubscriptionInfo, + @Inject(UseCaseType.AGENTS_INSERT_AI_ROWS) + private readonly insertAiRowsUseCase: IInsertAiRows, + @Inject(UseCaseType.AGENTS_UPDATE_AI_ROWS) + private readonly updateAiRowsUseCase: IUpdateAiRows, + @Inject(UseCaseType.AGENTS_DELETE_AI_ROWS) + private readonly deleteAiRowsUseCase: IDeleteAiRows, ) {} @ApiOperation({ summary: 'Validate an end-user JWT on behalf of the agents microservice' }) @@ -264,6 +279,88 @@ export class AgentsController { ); } + @ApiOperation({ + summary: 'Insert rows on behalf of a user — SiteNova chat data agent (sitenova plan 85)', + description: + 'Re-checks Cedar table:add for the given user, validates each row against the table structure, inserts ' + + 'the rows one by one (stops at the first failure and reports how many went in), writes the audit log and ' + + 'fires the ADD_ROW table actions. At most 100 rows per call.', + }) + @ApiResponse({ status: 201, type: AiRowsInsertedRO }) + @ApiBody({ type: InsertAiRowsDto }) + @Timeout(!isTest() ? TimeoutDefaults.EXTENDED : TimeoutDefaults.EXTENDED_TEST) + @Post('/ai/data/:connectionId/rows/insert') + public async insertAiRows( + @SlugUuid('connectionId') connectionId: string, + @Body() body: InsertAiRowsDto, + ): Promise { + return await this.insertAiRowsUseCase.execute( + { + connectionId, + userId: body.userId, + masterPassword: body.masterPassword ?? null, + tableName: body.tableName, + rows: body.rows, + }, + InTransactionEnum.OFF, + ); + } + + @ApiOperation({ + summary: 'Update rows by primary key on behalf of a user — SiteNova chat data agent (sitenova plan 85)', + description: + 'Re-checks Cedar table:edit for the given user, applies one set of new values to every listed primary key ' + + '(a key that names no row refuses the whole call before any write), writes the audit log and fires the ' + + 'UPDATE_ROW table actions. At most 100 keys per call.', + }) + @ApiResponse({ status: 201, type: AiRowsUpdatedRO }) + @ApiBody({ type: UpdateAiRowsDto }) + @Timeout(!isTest() ? TimeoutDefaults.EXTENDED : TimeoutDefaults.EXTENDED_TEST) + @Post('/ai/data/:connectionId/rows/update') + public async updateAiRows( + @SlugUuid('connectionId') connectionId: string, + @Body() body: UpdateAiRowsDto, + ): Promise { + return await this.updateAiRowsUseCase.execute( + { + connectionId, + userId: body.userId, + masterPassword: body.masterPassword ?? null, + tableName: body.tableName, + primaryKeys: body.primaryKeys, + newValues: body.newValues, + }, + InTransactionEnum.OFF, + ); + } + + @ApiOperation({ + summary: 'Delete rows by primary key on behalf of a user — SiteNova chat data agent (sitenova plan 85)', + description: + 'Re-checks Cedar table:delete for the given user, deletes every listed primary key in one statement (a key ' + + 'that names no row refuses the whole call before any write), writes the audit log and fires the DELETE_ROW ' + + 'table actions. At most 100 keys per call.', + }) + @ApiResponse({ status: 201, type: AiRowsDeletedRO }) + @ApiBody({ type: DeleteAiRowsDto }) + @Timeout(!isTest() ? TimeoutDefaults.EXTENDED : TimeoutDefaults.EXTENDED_TEST) + @Post('/ai/data/:connectionId/rows/delete') + public async deleteAiRows( + @SlugUuid('connectionId') connectionId: string, + @Body() body: DeleteAiRowsDto, + ): Promise { + return await this.deleteAiRowsUseCase.execute( + { + connectionId, + userId: body.userId, + masterPassword: body.masterPassword ?? null, + tableName: body.tableName, + primaryKeys: body.primaryKeys, + }, + InTransactionEnum.OFF, + ); + } + @ApiOperation({ summary: 'Run the AI settings/widgets scan, streaming progress chunks' }) @ApiResponse({ status: 201, description: 'Streams progress as newline-delimited JSON chunks.' }) @ApiBody({ type: AiDataRequestBaseDto }) diff --git a/backend/src/microservices/agents-microservice/agents.module.ts b/backend/src/microservices/agents-microservice/agents.module.ts index 9194ac027..8ef5352c2 100644 --- a/backend/src/microservices/agents-microservice/agents.module.ts +++ b/backend/src/microservices/agents-microservice/agents.module.ts @@ -4,6 +4,7 @@ import { SaaSAuthMiddleware } from '../../authorization/saas-auth.middleware.js' import { GlobalDatabaseContext } from '../../common/application/global-database-context.js'; import { BaseType, UseCaseType } from '../../common/data-injection.tokens.js'; import { AgentsController } from './agents.controller.js'; +import { DeleteAiRowsUseCase } from './use-cases/delete-ai-rows.use.case.js'; import { ExecuteAiAggregationPipelineUseCase } from './use-cases/execute-ai-aggregation-pipeline.use.case.js'; import { ExecuteAiRawQueryUseCase } from './use-cases/execute-ai-raw-query.use.case.js'; import { GetAiConnectionContextUseCase } from './use-cases/get-ai-connection-context.use.case.js'; @@ -11,9 +12,11 @@ import { GetAiConnectionTablesUseCase } from './use-cases/get-ai-connection-tabl import { GetAiSampleRowsUseCase } from './use-cases/get-ai-sample-rows.use.case.js'; import { GetAiTableStructureUseCase } from './use-cases/get-ai-table-structure.use.case.js'; import { GetCompanySubscriptionInfoUseCase } from './use-cases/get-company-subscription-info.use.case.js'; +import { InsertAiRowsUseCase } from './use-cases/insert-ai-rows.use.case.js'; import { ScanAndCreateSettingsUseCase } from './use-cases/scan-and-create-settings.use.case.js'; import { SetPublicPermissionsUseCase } from './use-cases/set-public-permissions.use.case.js'; import { SetSiteRuntimePolicyUseCase } from './use-cases/set-site-runtime-policy.use.case.js'; +import { UpdateAiRowsUseCase } from './use-cases/update-ai-rows.use.case.js'; import { ValidateConnectionEditUseCase } from './use-cases/validate-connection-edit.use.case.js'; import { ValidateTableAiRequestUseCase } from './use-cases/validate-table-ai-request.use.case.js'; import { ValidateUserTokenUseCase } from './use-cases/validate-user-token.use.case.js'; @@ -77,6 +80,18 @@ import { ValidateUserTokenUseCase } from './use-cases/validate-user-token.use.ca provide: UseCaseType.AGENTS_GET_COMPANY_SUBSCRIPTION_INFO, useClass: GetCompanySubscriptionInfoUseCase, }, + { + provide: UseCaseType.AGENTS_INSERT_AI_ROWS, + useClass: InsertAiRowsUseCase, + }, + { + provide: UseCaseType.AGENTS_UPDATE_AI_ROWS, + useClass: UpdateAiRowsUseCase, + }, + { + provide: UseCaseType.AGENTS_DELETE_AI_ROWS, + useClass: DeleteAiRowsUseCase, + }, ], controllers: [AgentsController], }) diff --git a/backend/src/microservices/agents-microservice/data-structures/agents-responses.ds.ts b/backend/src/microservices/agents-microservice/data-structures/agents-responses.ds.ts index 6d5068b24..0ebb20e32 100644 --- a/backend/src/microservices/agents-microservice/data-structures/agents-responses.ds.ts +++ b/backend/src/microservices/agents-microservice/data-structures/agents-responses.ds.ts @@ -98,3 +98,39 @@ export class CompanySubscriptionInfoRO { @ApiProperty() isPaymentMethodAdded: boolean; } + +export class AiRowWriteFailureRO { + @ApiProperty({ description: 'Zero-based index of the first row that failed. The rows before it were inserted.' }) + rowIndex: number; + + @ApiProperty({ description: 'The database or validation message for that row.' }) + message: string; +} + +export class AiRowsInsertedRO { + @ApiProperty({ description: 'How many rows the call inserted.' }) + insertedCount: number; + + @ApiProperty({ + type: 'array', + items: { type: 'object', additionalProperties: true }, + description: 'The primary key of each inserted row, in request order.', + }) + insertedPrimaryKeys: Array>; + + @ApiPropertyOptional({ + type: AiRowWriteFailureRO, + description: 'Present when a row failed. Inserts stop at the first failure.', + }) + failure?: AiRowWriteFailureRO; +} + +export class AiRowsUpdatedRO { + @ApiProperty({ description: 'How many rows the call updated.' }) + updatedCount: number; +} + +export class AiRowsDeletedRO { + @ApiProperty({ description: 'How many rows the call deleted.' }) + deletedCount: number; +} diff --git a/backend/src/microservices/agents-microservice/data-structures/agents.ds.ts b/backend/src/microservices/agents-microservice/data-structures/agents.ds.ts index 9fffad5cd..c95dba558 100644 --- a/backend/src/microservices/agents-microservice/data-structures/agents.ds.ts +++ b/backend/src/microservices/agents-microservice/data-structures/agents.ds.ts @@ -64,3 +64,20 @@ export class ScanAndCreateSettingsDs extends AiDataRequestDs { export class GetCompanySubscriptionInfoDs { userId: string; } + +// Row writes for the SiteNova chat data agent (sitenova plan 85). +export class InsertAiRowsDs extends AiDataRequestDs { + tableName: string; + rows: Array>; +} + +export class UpdateAiRowsDs extends AiDataRequestDs { + tableName: string; + primaryKeys: Array>; + newValues: Record; +} + +export class DeleteAiRowsDs extends AiDataRequestDs { + tableName: string; + primaryKeys: Array>; +} diff --git a/backend/src/microservices/agents-microservice/dto/agents-ai-data.dtos.ts b/backend/src/microservices/agents-microservice/dto/agents-ai-data.dtos.ts index 8985dad4b..355f66025 100644 --- a/backend/src/microservices/agents-microservice/dto/agents-ai-data.dtos.ts +++ b/backend/src/microservices/agents-microservice/dto/agents-ai-data.dtos.ts @@ -1,5 +1,17 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Min } from 'class-validator'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsInt, + IsNotEmpty, + IsNotEmptyObject, + IsObject, + IsOptional, + IsString, + IsUUID, + Min, +} from 'class-validator'; export class AiDataRequestBaseDto { @ApiProperty() @@ -57,3 +69,70 @@ export class ExecuteAiAggregationPipelineDto extends AiDataRequestBaseDto { @IsNotEmpty() pipeline: string; } + +// Row writes for the SiteNova chat data agent (sitenova plan 85). Each call names one table and +// at most AI_ROW_WRITE_MAX_ROWS rows, so one model tool call has a bounded blast radius. +export const AI_ROW_WRITE_MAX_ROWS = 100; + +export class InsertAiRowsDto extends AiDataRequestBaseDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + tableName: string; + + @ApiProperty({ + type: 'array', + items: { type: 'object', additionalProperties: true }, + description: `Rows to insert, column name to value. 1 to ${AI_ROW_WRITE_MAX_ROWS} rows.`, + }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(AI_ROW_WRITE_MAX_ROWS) + @IsObject({ each: true }) + rows: Array>; +} + +export class UpdateAiRowsDto extends AiDataRequestBaseDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + tableName: string; + + @ApiProperty({ + type: 'array', + items: { type: 'object', additionalProperties: true }, + description: `Primary keys of the rows to update. 1 to ${AI_ROW_WRITE_MAX_ROWS} keys.`, + }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(AI_ROW_WRITE_MAX_ROWS) + @IsObject({ each: true }) + primaryKeys: Array>; + + @ApiProperty({ + type: 'object', + additionalProperties: true, + description: 'Column name to new value, applied to every listed row.', + }) + @IsObject() + @IsNotEmptyObject() + newValues: Record; +} + +export class DeleteAiRowsDto extends AiDataRequestBaseDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + tableName: string; + + @ApiProperty({ + type: 'array', + items: { type: 'object', additionalProperties: true }, + description: `Primary keys of the rows to delete. 1 to ${AI_ROW_WRITE_MAX_ROWS} keys.`, + }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(AI_ROW_WRITE_MAX_ROWS) + @IsObject({ each: true }) + primaryKeys: Array>; +} diff --git a/backend/src/microservices/agents-microservice/use-cases/agents-use-cases.interface.ts b/backend/src/microservices/agents-microservice/use-cases/agents-use-cases.interface.ts index d2203083b..aa6f85347 100644 --- a/backend/src/microservices/agents-microservice/use-cases/agents-use-cases.interface.ts +++ b/backend/src/microservices/agents-microservice/use-cases/agents-use-cases.interface.ts @@ -1,14 +1,17 @@ import { InTransactionEnum } from '../../../enums/in-transaction.enum.js'; import { AiDataRequestDs, + DeleteAiRowsDs, ExecuteAiAggregationPipelineDs, ExecuteAiRawQueryDs, GetAiSampleRowsDs, GetAiTableStructureDs, GetCompanySubscriptionInfoDs, + InsertAiRowsDs, ScanAndCreateSettingsDs, SetPublicPermissionsDs, SetSiteRuntimePolicyDs, + UpdateAiRowsDs, ValidateConnectionEditDs, ValidateTableAiRequestDs, ValidateUserTokenDs, @@ -17,6 +20,9 @@ import { AiConnectionContextRO, AiConnectionTablesRO, AiQueryResultRO, + AiRowsDeletedRO, + AiRowsInsertedRO, + AiRowsUpdatedRO, AiSampleRowsRO, CompanySubscriptionInfoRO, PermissionAllowedRO, @@ -79,3 +85,16 @@ export interface IGetCompanySubscriptionInfo { inTransaction: InTransactionEnum, ): Promise; } + +// Row writes for the SiteNova chat data agent (sitenova plan 85). +export interface IInsertAiRows { + execute(inputData: InsertAiRowsDs, inTransaction: InTransactionEnum): Promise; +} + +export interface IUpdateAiRows { + execute(inputData: UpdateAiRowsDs, inTransaction: InTransactionEnum): Promise; +} + +export interface IDeleteAiRows { + execute(inputData: DeleteAiRowsDs, inTransaction: InTransactionEnum): Promise; +} diff --git a/backend/src/microservices/agents-microservice/use-cases/delete-ai-rows.use.case.ts b/backend/src/microservices/agents-microservice/use-cases/delete-ai-rows.use.case.ts new file mode 100644 index 000000000..2b4ed1b10 --- /dev/null +++ b/backend/src/microservices/agents-microservice/use-cases/delete-ai-rows.use.case.ts @@ -0,0 +1,96 @@ +import { HttpException, HttpStatus, Inject, Injectable, Scope } from '@nestjs/common'; +import AbstractUseCase from '../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../common/data-injection.tokens.js'; +import { CedarAuthorizationService } from '../../../entities/cedar-authorization/cedar-authorization.service.js'; +import { TableActionActivationService } from '../../../entities/table-actions/table-actions-module/table-action-activation.service.js'; +import { TableLogsService } from '../../../entities/table-logs/table-logs.service.js'; +import { LogOperationTypeEnum } from '../../../enums/log-operation-type.enum.js'; +import { OperationResultStatusEnum } from '../../../enums/operation-result-status.enum.js'; +import { TableActionEventEnum } from '../../../enums/table-action-event-enum.js'; +import { getErrorMessage } from '../../../helpers/get-error-message.js'; +import { DeleteAiRowsDs } from '../data-structures/agents.ds.js'; +import { AiRowsDeletedRO } from '../data-structures/agents-responses.ds.js'; +import { setupAiConnection } from '../utils/ai-data-access.helpers.js'; +import { + assertUserCanWriteTable, + loadRowsForAiPrimaryKeys, + normalizeAiPrimaryKeys, + prepareAiTableWrite, + primaryKeyIndexKey, +} from '../utils/ai-row-write.helpers.js'; +import { IDeleteAiRows } from './agents-use-cases.interface.js'; + +// Deletes rows by primary key on behalf of a RocketAdmin user for the SiteNova chat data agent +// (sitenova plan 85). The steps mirror DeleteRowsFromTableUseCase (plan 75): the Cedar +// table:delete check, the key validation, the old rows for the audit log and one bulk DELETE. The +// DELETE_ROW table actions fire per row, as on the panel's single-row route. A key that names no +// row refuses the whole call before any write. +@Injectable({ scope: Scope.REQUEST }) +export class DeleteAiRowsUseCase extends AbstractUseCase implements IDeleteAiRows { + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + private readonly cedarAuthService: CedarAuthorizationService, + private readonly tableLogsService: TableLogsService, + private readonly tableActionActivationService: TableActionActivationService, + ) { + super(); + } + + protected async implementation(inputData: DeleteAiRowsDs): Promise { + const { connectionId, userId, masterPassword, tableName } = inputData; + + const { + foundConnection, + dataAccessObject: dao, + userEmail, + } = await setupAiConnection(this._dbContext, connectionId, masterPassword, userId); + await assertUserCanWriteTable(this.cedarAuthService, 'delete', userId, connectionId, tableName); + const context = await prepareAiTableWrite(this._dbContext, dao, connectionId, tableName, userEmail, 'delete'); + const primaryKeys = normalizeAiPrimaryKeys(inputData.primaryKeys, context); + const oldRowsByKey = await loadRowsForAiPrimaryKeys(dao, tableName, primaryKeys, context, userEmail); + + let operationResult = OperationResultStatusEnum.unknown; + let deletedCount = 0; + try { + deletedCount = await dao.bulkDeleteRowsInTable(tableName, primaryKeys, userEmail); + operationResult = OperationResultStatusEnum.successfully; + } catch (error) { + operationResult = OperationResultStatusEnum.unsuccessfully; + throw new HttpException({ message: getErrorMessage(error) }, HttpStatus.BAD_REQUEST); + } finally { + await this.tableLogsService.createAndSaveNewLogsUtil( + primaryKeys.map((primaryKey) => ({ + operationStatusResult: operationResult, + row: { ...primaryKey }, + old_data: { ...(oldRowsByKey.get(primaryKeyIndexKey(primaryKey, context.primaryColumnNames)) ?? {}) }, + affected_primary_key: primaryKey as unknown as string, + })), + userId, + foundConnection, + tableName, + LogOperationTypeEnum.deleteRow, + ); + } + + const deleteActions = await this._dbContext.tableActionRepository.findTableActionsWithDeleteRowEvents( + connectionId, + tableName, + ); + for (const primaryKey of primaryKeys) { + await this.tableActionActivationService.activateTableActions( + deleteActions, + foundConnection, + primaryKey, + userId, + tableName, + TableActionEventEnum.DELETE_ROW, + ); + } + + // Some drivers report no count for a bulk DELETE. Every key was verified to exist above, so + // the request size is the honest fallback. + return { deletedCount: typeof deletedCount === 'number' && deletedCount > 0 ? deletedCount : primaryKeys.length }; + } +} diff --git a/backend/src/microservices/agents-microservice/use-cases/insert-ai-rows.use.case.ts b/backend/src/microservices/agents-microservice/use-cases/insert-ai-rows.use.case.ts new file mode 100644 index 000000000..5f621936d --- /dev/null +++ b/backend/src/microservices/agents-microservice/use-cases/insert-ai-rows.use.case.ts @@ -0,0 +1,134 @@ +import { Inject, Injectable, Scope } from '@nestjs/common'; +import AbstractUseCase from '../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../common/data-injection.tokens.js'; +import { CedarAuthorizationService } from '../../../entities/cedar-authorization/cedar-authorization.service.js'; +import { convertHexDataInRowUtil } from '../../../entities/table/utils/convert-hex-data-in-row.util.js'; +import { hashPasswordsInRowUtil } from '../../../entities/table/utils/hash-passwords-in-row.util.js'; +import { processUuidsInRowUtil } from '../../../entities/table/utils/process-uuids-in-row-util.js'; +import { validateTableRowUtil } from '../../../entities/table/utils/validate-table-row.util.js'; +import { TableActionActivationService } from '../../../entities/table-actions/table-actions-module/table-action-activation.service.js'; +import { TableLogsService } from '../../../entities/table-logs/table-logs.service.js'; +import { LogOperationTypeEnum } from '../../../enums/log-operation-type.enum.js'; +import { OperationResultStatusEnum } from '../../../enums/operation-result-status.enum.js'; +import { TableActionEventEnum } from '../../../enums/table-action-event-enum.js'; +import { Messages } from '../../../exceptions/text/messages.js'; +import { getErrorMessage } from '../../../helpers/get-error-message.js'; +import { isObjectEmpty } from '../../../helpers/is-object-empty.js'; +import { toPrettyErrorsMsg } from '../../../helpers/to-pretty-errors-msg.js'; +import { InsertAiRowsDs } from '../data-structures/agents.ds.js'; +import { AiRowsInsertedRO } from '../data-structures/agents-responses.ds.js'; +import { setupAiConnection } from '../utils/ai-data-access.helpers.js'; +import { assertUserCanWriteTable, prepareAiTableWrite } from '../utils/ai-row-write.helpers.js'; +import { IInsertAiRows } from './agents-use-cases.interface.js'; + +// Inserts rows on behalf of a RocketAdmin user for the SiteNova chat data agent (sitenova plan +// 85). The steps mirror AddRowInTableUseCase: the Cedar table:add check, the row validation, the +// password / uuid / binary processing, one audit-log row per insert and the ADD_ROW table actions. +// The rows go in one by one, in request order. The first failure stops the call, and the response +// says how many rows went in before it, so the agent can report the exact result. +@Injectable({ scope: Scope.REQUEST }) +export class InsertAiRowsUseCase extends AbstractUseCase implements IInsertAiRows { + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + private readonly cedarAuthService: CedarAuthorizationService, + private readonly tableLogsService: TableLogsService, + private readonly tableActionActivationService: TableActionActivationService, + ) { + super(); + } + + protected async implementation(inputData: InsertAiRowsDs): Promise { + const { connectionId, userId, masterPassword, tableName, rows } = inputData; + + const { + foundConnection, + dataAccessObject: dao, + userEmail, + } = await setupAiConnection(this._dbContext, connectionId, masterPassword, userId); + await assertUserCanWriteTable(this.cedarAuthService, 'add', userId, connectionId, tableName); + const context = await prepareAiTableWrite(this._dbContext, dao, connectionId, tableName, userEmail, 'add'); + + const addActions = await this._dbContext.tableActionRepository.findTableActionsWithAddRowEvents( + connectionId, + tableName, + ); + + const insertedPrimaryKeys: Array> = []; + const logs: Array<{ + operationStatusResult: OperationResultStatusEnum; + row: Record; + old_data: Record; + affected_primary_key: string; + }> = []; + let failure: AiRowsInsertedRO['failure']; + + try { + for (const [rowIndex, row] of rows.entries()) { + const errors = validateTableRowUtil(row, context.tableStructure); + if (errors.length > 0) { + failure = { rowIndex, message: toPrettyErrorsMsg(errors) }; + break; + } + let processedRow = await hashPasswordsInRowUtil(row, context.tableWidgets); + processedRow = processUuidsInRowUtil(processedRow, context.tableWidgets); + processedRow = convertHexDataInRowUtil(processedRow, context.tableStructure); + let primaryKey: Record; + try { + primaryKey = (await dao.addRowInTable(tableName, processedRow, userEmail)) as Record; + } catch (error) { + const message = getErrorMessage(error); + failure = { + rowIndex, + message: message.includes('duplicate key value') ? Messages.CANT_INSERT_DUPLICATE_KEY : message, + }; + logs.push({ + operationStatusResult: OperationResultStatusEnum.unsuccessfully, + row: processedRow, + old_data: {}, + affected_primary_key: '' as string, + }); + break; + } + const affectedPrimaryKey = primaryKey && !isObjectEmpty(primaryKey) ? primaryKey : {}; + insertedPrimaryKeys.push(affectedPrimaryKey); + logs.push({ + operationStatusResult: OperationResultStatusEnum.successfully, + row: processedRow, + old_data: {}, + affected_primary_key: affectedPrimaryKey as unknown as string, + }); + if (addActions.length > 0) { + const addedRow = isObjectEmpty(affectedPrimaryKey) + ? processedRow + : await dao.getRowByPrimaryKey(tableName, affectedPrimaryKey, context.daoTableSettings, userEmail); + await this.tableActionActivationService.activateTableActions( + addActions, + foundConnection, + addedRow ?? processedRow, + userId, + tableName, + TableActionEventEnum.ADD_ROW, + ); + } + } + } finally { + if (logs.length > 0) { + await this.tableLogsService.createAndSaveNewLogsUtil( + logs, + userId, + foundConnection, + tableName, + LogOperationTypeEnum.addRow, + ); + } + } + + return { + insertedCount: insertedPrimaryKeys.length, + insertedPrimaryKeys, + ...(failure ? { failure } : {}), + }; + } +} diff --git a/backend/src/microservices/agents-microservice/use-cases/update-ai-rows.use.case.ts b/backend/src/microservices/agents-microservice/use-cases/update-ai-rows.use.case.ts new file mode 100644 index 000000000..e7fb855eb --- /dev/null +++ b/backend/src/microservices/agents-microservice/use-cases/update-ai-rows.use.case.ts @@ -0,0 +1,108 @@ +import { HttpException, HttpStatus, Inject, Injectable, Scope } from '@nestjs/common'; +import AbstractUseCase from '../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../common/data-injection.tokens.js'; +import { CedarAuthorizationService } from '../../../entities/cedar-authorization/cedar-authorization.service.js'; +import { convertHexDataInRowUtil } from '../../../entities/table/utils/convert-hex-data-in-row.util.js'; +import { hashPasswordsInRowUtil } from '../../../entities/table/utils/hash-passwords-in-row.util.js'; +import { processUuidsInRowUtil } from '../../../entities/table/utils/process-uuids-in-row-util.js'; +import { validateTableRowUtil } from '../../../entities/table/utils/validate-table-row.util.js'; +import { TableActionActivationService } from '../../../entities/table-actions/table-actions-module/table-action-activation.service.js'; +import { TableLogsService } from '../../../entities/table-logs/table-logs.service.js'; +import { LogOperationTypeEnum } from '../../../enums/log-operation-type.enum.js'; +import { OperationResultStatusEnum } from '../../../enums/operation-result-status.enum.js'; +import { TableActionEventEnum } from '../../../enums/table-action-event-enum.js'; +import { getErrorMessage } from '../../../helpers/get-error-message.js'; +import { toPrettyErrorsMsg } from '../../../helpers/to-pretty-errors-msg.js'; +import { UpdateAiRowsDs } from '../data-structures/agents.ds.js'; +import { AiRowsUpdatedRO } from '../data-structures/agents-responses.ds.js'; +import { setupAiConnection } from '../utils/ai-data-access.helpers.js'; +import { + assertUserCanWriteTable, + loadRowsForAiPrimaryKeys, + normalizeAiPrimaryKeys, + prepareAiTableWrite, + primaryKeyIndexKey, +} from '../utils/ai-row-write.helpers.js'; +import { IUpdateAiRows } from './agents-use-cases.interface.js'; + +// Updates rows by primary key on behalf of a RocketAdmin user for the SiteNova chat data agent +// (sitenova plan 85). One set of new values goes to every listed row. The steps mirror +// BulkUpdateRowsInTableUseCase: the Cedar table:edit check, the key validation, the old rows for +// the audit log, the password / uuid / binary processing and one bulk UPDATE. The UPDATE_ROW table +// actions fire per row, as on the panel's single-row route. A key that names no row refuses the +// whole call before any write. +@Injectable({ scope: Scope.REQUEST }) +export class UpdateAiRowsUseCase extends AbstractUseCase implements IUpdateAiRows { + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + private readonly cedarAuthService: CedarAuthorizationService, + private readonly tableLogsService: TableLogsService, + private readonly tableActionActivationService: TableActionActivationService, + ) { + super(); + } + + protected async implementation(inputData: UpdateAiRowsDs): Promise { + const { connectionId, userId, masterPassword, tableName, newValues } = inputData; + + const { + foundConnection, + dataAccessObject: dao, + userEmail, + } = await setupAiConnection(this._dbContext, connectionId, masterPassword, userId); + await assertUserCanWriteTable(this.cedarAuthService, 'update', userId, connectionId, tableName); + const context = await prepareAiTableWrite(this._dbContext, dao, connectionId, tableName, userEmail, 'update'); + const primaryKeys = normalizeAiPrimaryKeys(inputData.primaryKeys, context); + + const errors = validateTableRowUtil(newValues, context.tableStructure); + if (errors.length > 0) { + throw new HttpException({ message: toPrettyErrorsMsg(errors) }, HttpStatus.BAD_REQUEST); + } + const oldRowsByKey = await loadRowsForAiPrimaryKeys(dao, tableName, primaryKeys, context, userEmail); + + let processedValues = await hashPasswordsInRowUtil(newValues, context.tableWidgets); + processedValues = processUuidsInRowUtil(processedValues, context.tableWidgets); + processedValues = convertHexDataInRowUtil(processedValues, context.tableStructure); + + let operationResult = OperationResultStatusEnum.unknown; + try { + await dao.bulkUpdateRowsInTable(tableName, processedValues, primaryKeys, userEmail); + operationResult = OperationResultStatusEnum.successfully; + } catch (error) { + operationResult = OperationResultStatusEnum.unsuccessfully; + throw new HttpException({ message: getErrorMessage(error) }, HttpStatus.BAD_REQUEST); + } finally { + await this.tableLogsService.createAndSaveNewLogsUtil( + primaryKeys.map((primaryKey) => ({ + operationStatusResult: operationResult, + row: { ...newValues }, + old_data: { ...(oldRowsByKey.get(primaryKeyIndexKey(primaryKey, context.primaryColumnNames)) ?? {}) }, + affected_primary_key: primaryKey as unknown as string, + })), + userId, + foundConnection, + tableName, + LogOperationTypeEnum.updateRow, + ); + } + + const updateActions = await this._dbContext.tableActionRepository.findTableActionsWithUpdateRowEvents( + connectionId, + tableName, + ); + for (const primaryKey of primaryKeys) { + await this.tableActionActivationService.activateTableActions( + updateActions, + foundConnection, + primaryKey, + userId, + tableName, + TableActionEventEnum.UPDATE_ROW, + ); + } + + return { updatedCount: primaryKeys.length }; + } +} diff --git a/backend/src/microservices/agents-microservice/utils/ai-row-write.helpers.ts b/backend/src/microservices/agents-microservice/utils/ai-row-write.helpers.ts new file mode 100644 index 000000000..ca34716cc --- /dev/null +++ b/backend/src/microservices/agents-microservice/utils/ai-row-write.helpers.ts @@ -0,0 +1,155 @@ +import { ForbiddenException, HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { PrimaryKeyDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/primary-key.ds.js'; +import { TableSettingsDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/table-settings.ds.js'; +import { TableStructureDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/table-structure.ds.js'; +import { buildDAOsTableSettingsDs } from '@rocketadmin/shared-code/dist/src/helpers/data-structures-builders/table-settings.ds.builder.js'; +import { IDataAccessObject } from '@rocketadmin/shared-code/dist/src/shared/interfaces/data-access-object.interface.js'; +import { IDataAccessObjectAgent } from '@rocketadmin/shared-code/dist/src/shared/interfaces/data-access-object-agent.interface.js'; +import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; +import { CedarAction } from '../../../entities/cedar-authorization/cedar-action-map.js'; +import { CedarAuthorizationService } from '../../../entities/cedar-authorization/cedar-authorization.service.js'; +import { buildCommonTableSettingsInput } from '../../../entities/table/utils/build-common-table-settings-input.util.js'; +import { convertHexDataInPrimaryKeyUtil } from '../../../entities/table/utils/convert-hex-data-in-primary-key.util.js'; +import { TableWidgetEntity } from '../../../entities/widget/table-widget.entity.js'; +import { TableNotFoundException } from '../../../exceptions/custom-exceptions/table-not-found-exception.js'; +import { Messages } from '../../../exceptions/text/messages.js'; +import { compareArrayElements } from '../../../helpers/compare-array-elements.js'; + +// Shared steps of the three AI row-write use cases (sitenova plan 85). The chat data agent writes +// rows on behalf of a RocketAdmin user, so each write gets the checks the admin panel's own row +// routes apply: the table-level Cedar action, the table existence, the view rule, and the +// per-table `can_add` / `can_update` / `can_delete` settings. + +const logger = new Logger('AgentsAiRowWrite'); + +export type AiRowWriteOperation = 'add' | 'update' | 'delete'; + +const CEDAR_ACTION_FOR_OPERATION: Record = { + add: CedarAction.TableAdd, + update: CedarAction.TableEdit, + delete: CedarAction.TableDelete, +}; + +export type AiTableWriteContext = { + tableStructure: Array; + primaryColumns: Array; + primaryColumnNames: Array; + tableWidgets: Array; + daoTableSettings: TableSettingsDS; +}; + +// The same Cedar check the panel's TableAddGuard / TableEditGuard / TableDeleteGuard make. A +// refusal is logged, because the agent reported the user as the actor of the write. +export async function assertUserCanWriteTable( + cedarAuthService: CedarAuthorizationService, + operation: AiRowWriteOperation, + userId: string, + connectionId: string, + tableName: string, +): Promise { + const allowed = await cedarAuthService.validate({ + userId, + action: CEDAR_ACTION_FOR_OPERATION[operation], + connectionId, + tableName, + }); + if (!allowed) { + logger.warn( + `AI row ${operation} REFUSED for user ${userId} on connection ${connectionId}: no permission for table "${tableName}"`, + ); + throw new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS); + } +} + +// Loads what a write needs and applies the table rules. Throws the same exceptions the panel's +// row use cases throw, so the agent reads the same messages a RocketAdmin user would see. +export async function prepareAiTableWrite( + dbContext: IGlobalDatabaseContext, + dao: IDataAccessObject | IDataAccessObjectAgent, + connectionId: string, + tableName: string, + userEmail: string, + operation: AiRowWriteOperation, +): Promise { + const tablesInConnection = await dao.getTablesFromDB(userEmail); + if (!tablesInConnection.some((table) => table.tableName === tableName)) { + throw new TableNotFoundException(); + } + if (await dao.isView(tableName, userEmail)) { + throw new HttpException({ message: Messages.CANT_UPDATE_TABLE_VIEW }, HttpStatus.BAD_REQUEST); + } + const [tableStructure, primaryColumns, tableSettings, tableWidgets] = await Promise.all([ + dao.getTableStructure(tableName, userEmail), + dao.getTablePrimaryColumns(tableName, userEmail), + dbContext.tableSettingsRepository.findTableSettings(connectionId, tableName), + dbContext.tableWidgetsRepository.findTableWidgets(connectionId, tableName), + ]); + const operationAllowedBySettings = + !tableSettings || + (operation === 'add' && tableSettings.can_add) || + (operation === 'update' && tableSettings.can_update) || + (operation === 'delete' && tableSettings.can_delete); + if (!operationAllowedBySettings) { + throw new HttpException({ message: Messages.CANT_DO_TABLE_OPERATION }, HttpStatus.FORBIDDEN); + } + return { + tableStructure, + primaryColumns, + primaryColumnNames: primaryColumns.map((column) => column.column_name), + tableWidgets, + daoTableSettings: buildDAOsTableSettingsDs(buildCommonTableSettingsInput(tableSettings), null), + }; +} + +// Every key must name exactly the table's primary columns. Binary key values arrive as hex and +// become buffers here, as on the panel's row routes. +export function normalizeAiPrimaryKeys( + primaryKeys: Array>, + context: AiTableWriteContext, +): Array> { + if (context.primaryColumnNames.length === 0) { + throw new HttpException({ message: Messages.PRIMARY_KEY_INVALID }, HttpStatus.BAD_REQUEST); + } + return primaryKeys.map((primaryKey) => { + const converted = convertHexDataInPrimaryKeyUtil({ ...primaryKey }, context.tableStructure); + for (const key of Object.keys(converted)) { + // eslint-disable-next-line security/detect-object-injection + if (converted[key] === undefined) delete converted[key]; + } + if (!compareArrayElements(context.primaryColumnNames, Object.keys(converted))) { + throw new HttpException({ message: Messages.PRIMARY_KEY_INVALID }, HttpStatus.BAD_REQUEST); + } + return converted; + }); +} + +export function primaryKeyIndexKey(row: Record, primaryColumnNames: Array): string { + // eslint-disable-next-line security/detect-object-injection + return primaryColumnNames.map((column) => String(row[column])).join('\u0000'); +} + +// Reads the rows behind the keys (the audit log's "old data") and refuses the whole call when a +// key names no row, so a write never silently skips a row the agent reported as changed. +export async function loadRowsForAiPrimaryKeys( + dao: IDataAccessObject | IDataAccessObjectAgent, + tableName: string, + primaryKeys: Array>, + context: AiTableWriteContext, + userEmail: string, +): Promise>> { + const rows = await dao.bulkGetRowsFromTableByPrimaryKeys(tableName, primaryKeys, context.daoTableSettings, userEmail); + const rowsByKey = new Map>(); + for (const row of rows) { + rowsByKey.set(primaryKeyIndexKey(row, context.primaryColumnNames), row); + } + const missing = primaryKeys.find( + (primaryKey) => !rowsByKey.has(primaryKeyIndexKey(primaryKey, context.primaryColumnNames)), + ); + if (missing) { + throw new HttpException( + { message: `${Messages.ROW_PRIMARY_KEY_NOT_FOUND}: ${JSON.stringify(missing)}` }, + HttpStatus.BAD_REQUEST, + ); + } + return rowsByKey; +} diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-agents-microservice-row-writes-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-agents-microservice-row-writes-e2e.test.ts new file mode 100644 index 000000000..d490956a8 --- /dev/null +++ b/backend/test/ava-tests/non-saas-tests/non-saas-agents-microservice-row-writes-e2e.test.ts @@ -0,0 +1,314 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable security/detect-object-injection */ +import { faker } from '@faker-js/faker'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import test from 'ava'; +import { ValidationError } from 'class-validator'; +import cookieParser from 'cookie-parser'; +import jwt from 'jsonwebtoken'; +import request from 'supertest'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; +import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; +import { Cacher } from '../../../src/helpers/cache/cacher.js'; +import { appConfig } from '../../../src/shared/config/app-config.js'; +import { DatabaseModule } from '../../../src/shared/database/database.module.js'; +import { DatabaseService } from '../../../src/shared/database/database.service.js'; +import { MockFactory } from '../../mock.factory.js'; +import { createTestTable } from '../../utils/create-test-table.js'; +import { dropTestTables } from '../../utils/drop-test-tables.js'; +import { getTestData } from '../../utils/get-test-data.js'; +import { + createInitialTestUser, + registerUserAndReturnUserInfo, +} from '../../utils/register-user-and-return-user-info.js'; +import { setSaasEnvVariable } from '../../utils/set-saas-env-variable.js'; +import { TestUtils } from '../../utils/test.utils.js'; + +// The SiteNova chat data agent's row writes (sitenova plan 85): the three internal endpoints that +// insert, update and delete rows on behalf of a RocketAdmin user. The suite pins the happy path on +// Postgres (the hosted-database engine), the Cedar refusal for a user without permissions, the +// microservice JWT gate and the "a key that names no row refuses the whole call" rule. + +const mockFactory = new MockFactory(); +let app: INestApplication; +let _testUtils: TestUtils; +const testTables: Array = []; +let currentTest; + +function microserviceAuthHeader(): string { + const secret = appConfig.auth.microserviceJwtSecret as string; + return `Bearer ${jwt.sign({ request_id: faker.string.uuid() }, secret, { expiresIn: '1h' })}`; +} + +function userIdFromCookieToken(cookieToken: string): string { + const decoded = jwt.decode(cookieToken.split('=')[1]) as { id: string }; + return decoded.id; +} + +test.before(async () => { + setSaasEnvVariable(); + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService, TestUtils], + }).compile(); + app = moduleFixture.createNestApplication(); + _testUtils = moduleFixture.get(TestUtils); + + app.use(cookieParser()); + app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger))); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory(validationErrors: ValidationError[] = []) { + return new ValidationException(validationErrors); + }, + }), + ); + await app.init(); + await createInitialTestUser(app); + app.getHttpServer().listen(0); +}); + +test.after(async () => { + try { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + await dropTestTables(testTables, connectionToTestDB); + await Cacher.clearAllCache(); + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } +}); + +async function createConnectionAndTable(): Promise<{ + token: string; + userId: string; + connectionId: string; + testTableName: string; + testTableColumnName: string; + testTableSecondColumnName: string; +}> { + const connectionToTestDB = getTestData(mockFactory).connectionToPostgres; + const token = (await registerUserAndReturnUserInfo(app)).token; + const userId = userIdFromCookieToken(token); + const { testTableName, testTableColumnName, testTableSecondColumnName } = await createTestTable(connectionToTestDB); + testTables.push(testTableName); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + return { + token, + userId, + connectionId: JSON.parse(createConnectionResponse.text).id, + testTableName, + testTableColumnName, + testTableSecondColumnName, + }; +} + +function rowsRequest( + operation: 'insert' | 'update' | 'delete', + connectionId: string, + body: Record, + withAuth = true, +): request.Test { + const req = request(app.getHttpServer()) + .post(`/internal/agents/ai/data/${connectionId}/rows/${operation}`) + .send(body) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + return withAuth ? req.set('Authorization', microserviceAuthHeader()) : req; +} + +async function readRow(token: string, connectionId: string, tableName: string, id: number): Promise { + return await request(app.getHttpServer()) + .get(`/table/crud/${connectionId}?tableName=${tableName}&id=${id}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); +} + +async function countRows(userId: string, connectionId: string, tableName: string): Promise { + const response = await request(app.getHttpServer()) + .post(`/internal/agents/ai/data/${connectionId}/raw-query`) + .send({ userId, tableName, query: `SELECT count(*)::int AS total FROM "${tableName}"` }) + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + // The Postgres DAO returns the driver's result object; the rows are under `rows`. + return JSON.parse(response.text).result.rows[0].total; +} + +currentTest = 'POST /internal/agents/ai/data/:connectionId/rows/insert|update|delete (internal, microservice JWT)'; + +test.serial(`${currentTest} inserts, updates and deletes rows for the connection owner`, async (t) => { + const { token, userId, connectionId, testTableName, testTableColumnName, testTableSecondColumnName } = + await createConnectionAndTable(); + t.is(await countRows(userId, connectionId, testTableName), 42); + + const insertResponse = await rowsRequest('insert', connectionId, { + userId, + tableName: testTableName, + rows: [ + { [testTableColumnName]: 'Agent row one', [testTableSecondColumnName]: 'one@example.com' }, + { [testTableColumnName]: 'Agent row two', [testTableSecondColumnName]: 'two@example.com' }, + ], + }); + t.is(insertResponse.status, 201, insertResponse.text); + const inserted = JSON.parse(insertResponse.text); + t.is(inserted.insertedCount, 2); + t.is(inserted.insertedPrimaryKeys.length, 2); + t.is(inserted.failure, undefined); + const [firstId, secondId] = inserted.insertedPrimaryKeys.map((key: { id: number }) => key.id); + t.is(typeof firstId, 'number'); + t.is(await countRows(userId, connectionId, testTableName), 44); + + const firstRow = await readRow(token, connectionId, testTableName, firstId); + t.is(firstRow.status, 200); + t.is(JSON.parse(firstRow.text).row[testTableColumnName], 'Agent row one'); + + const updateResponse = await rowsRequest('update', connectionId, { + userId, + tableName: testTableName, + primaryKeys: [{ id: firstId }, { id: secondId }], + newValues: { [testTableColumnName]: 'Agent row renamed' }, + }); + t.is(updateResponse.status, 201, updateResponse.text); + t.is(JSON.parse(updateResponse.text).updatedCount, 2); + const renamed = await readRow(token, connectionId, testTableName, secondId); + t.is(JSON.parse(renamed.text).row[testTableColumnName], 'Agent row renamed'); + t.is(JSON.parse(renamed.text).row[testTableSecondColumnName], 'two@example.com'); + + const deleteResponse = await rowsRequest('delete', connectionId, { + userId, + tableName: testTableName, + primaryKeys: [{ id: firstId }, { id: secondId }], + }); + t.is(deleteResponse.status, 201, deleteResponse.text); + t.is(JSON.parse(deleteResponse.text).deletedCount, 2); + t.is(await countRows(userId, connectionId, testTableName), 42); +}); + +test.serial(`${currentTest} stops an insert at the first failing row and reports the rows before it`, async (t) => { + const { userId, connectionId, testTableName, testTableColumnName } = await createConnectionAndTable(); + + const response = await rowsRequest('insert', connectionId, { + userId, + tableName: testTableName, + rows: [ + { [testTableColumnName]: 'Goes in' }, + // `id` is the autogenerated primary key: the row validator refuses a value for it. + { id: 999999, [testTableColumnName]: 'Refused' }, + { [testTableColumnName]: 'Never reached' }, + ], + }); + t.is(response.status, 201, response.text); + const ro = JSON.parse(response.text); + t.is(ro.insertedCount, 1); + t.is(ro.failure.rowIndex, 1); + t.is(typeof ro.failure.message, 'string'); + t.is(await countRows(userId, connectionId, testTableName), 43); +}); + +test.serial(`${currentTest} refuses an update or delete whose key names no row, before any write`, async (t) => { + const { userId, connectionId, testTableName, testTableColumnName } = await createConnectionAndTable(); + + const update = await rowsRequest('update', connectionId, { + userId, + tableName: testTableName, + primaryKeys: [{ id: 1 }, { id: 987654321 }], + newValues: { [testTableColumnName]: 'Must not land' }, + }); + t.is(update.status, 400); + t.is(JSON.parse(update.text).message.includes('not found'), true); + + const remove = await rowsRequest('delete', connectionId, { + userId, + tableName: testTableName, + primaryKeys: [{ id: 987654321 }], + }); + t.is(remove.status, 400); + t.is(await countRows(userId, connectionId, testTableName), 42); + + const badKey = await rowsRequest('delete', connectionId, { + userId, + tableName: testTableName, + primaryKeys: [{ [testTableColumnName]: 'Vasia' }], + }); + t.is(badKey.status, 400); + t.is(await countRows(userId, connectionId, testTableName), 42); +}); + +test.serial(`${currentTest} rejects calls without the microservice JWT (401)`, async (t) => { + const { userId, connectionId, testTableName, testTableColumnName } = await createConnectionAndTable(); + + const response = await rowsRequest( + 'insert', + connectionId, + { userId, tableName: testTableName, rows: [{ [testTableColumnName]: 'x' }] }, + false, + ); + t.is(response.status, 401); +}); + +test.serial( + `${currentTest} denies a user with no permissions on the connection (403), nothing is written`, + async (t) => { + const { userId, connectionId, testTableName, testTableColumnName } = await createConnectionAndTable(); + const foreignUserToken = (await registerUserAndReturnUserInfo(app)).token; + const foreignUserId = userIdFromCookieToken(foreignUserToken); + + const insert = await rowsRequest('insert', connectionId, { + userId: foreignUserId, + tableName: testTableName, + rows: [{ [testTableColumnName]: 'x' }], + }); + t.is(insert.status, 403); + const update = await rowsRequest('update', connectionId, { + userId: foreignUserId, + tableName: testTableName, + primaryKeys: [{ id: 1 }], + newValues: { [testTableColumnName]: 'x' }, + }); + t.is(update.status, 403); + const remove = await rowsRequest('delete', connectionId, { + userId: foreignUserId, + tableName: testTableName, + primaryKeys: [{ id: 1 }], + }); + t.is(remove.status, 403); + t.is(await countRows(userId, connectionId, testTableName), 42); + }, +); + +test.serial( + `${currentTest} validates the body: an empty list, more than 100 rows and an unknown table are 400`, + async (t) => { + const { userId, connectionId, testTableName, testTableColumnName } = await createConnectionAndTable(); + + const empty = await rowsRequest('insert', connectionId, { userId, tableName: testTableName, rows: [] }); + t.is(empty.status, 400); + + const tooMany = await rowsRequest('delete', connectionId, { + userId, + tableName: testTableName, + primaryKeys: Array.from({ length: 101 }, (_unused, index) => ({ id: index + 1 })), + }); + t.is(tooMany.status, 400); + + const unknownTable = await rowsRequest('insert', connectionId, { + userId, + tableName: `${testTableName}_missing`, + rows: [{ [testTableColumnName]: 'x' }], + }); + t.is(unknownTable.status, 400); + t.is(await countRows(userId, connectionId, testTableName), 42); + }, +);