Implement 'Add Password' and 'Change Permissions' tools in V2 (#4195)

# Description of Changes
Implement Add Password and Change Permissions tools in V2 (both in one
because Change Permissions is a fake endpoint which just calls Add
Password behind the scenes).

---------

Co-authored-by: James <[email protected]>
This commit is contained in:
James Brunton
2025-08-14 14:27:23 +01:00
committed by GitHub
co-authored by James
parent 0ea4410dd3
commit ecf30d1028
31 changed files with 1936 additions and 88 deletions
@@ -0,0 +1,144 @@
import { describe, expect, test, vi, beforeEach, MockedFunction } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useAddPasswordOperation } from './useAddPasswordOperation';
import type { AddPasswordFullParameters, AddPasswordParameters } from './useAddPasswordParameters';
// Mock the useToolOperation hook
vi.mock('../shared/useToolOperation', () => ({
useToolOperation: vi.fn()
}));
// Mock the translation hook
const mockT = vi.fn((key: string) => `translated-${key}`);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Mock the error handler
vi.mock('../../../utils/toolErrorHandler', () => ({
createStandardErrorHandler: vi.fn(() => 'error-handler-function')
}));
// Import the mocked function
import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation';
describe('useAddPasswordOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = (): ToolOperationConfig<AddPasswordFullParameters> => mockUseToolOperation.mock.calls[0][0];
const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [],
thumbnails: [],
downloadUrl: null,
downloadFilename: '',
isLoading: false,
errorMessage: null,
status: '',
isGeneratingThumbnails: false,
progress: null,
executeOperation: vi.fn(),
resetResults: vi.fn(),
clearError: vi.fn(),
cancelOperation: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
test('should configure useToolOperation with correct parameters', () => {
renderHook(() => useAddPasswordOperation());
expect(mockUseToolOperation).toHaveBeenCalledWith({
operationType: 'addPassword',
endpoint: '/api/v1/security/add-password',
buildFormData: expect.any(Function),
filePrefix: 'translated-addPassword.filenamePrefix_',
multiFileEndpoint: false,
getErrorMessage: 'error-handler-function'
});
});
test('should return the result from useToolOperation', () => {
const { result } = renderHook(() => useAddPasswordOperation());
expect(result.current).toBe(mockToolOperationReturn);
});
test.each([
{
description: 'with all parameters filled',
password: 'user-password',
ownerPassword: 'owner-password',
keyLength: 256
},
{
description: 'with empty passwords',
password: '',
ownerPassword: '',
keyLength: 128
},
{
description: 'with 40-bit key length',
password: 'test',
ownerPassword: '',
keyLength: 40
}
])('should create form data correctly $description', ({ password, ownerPassword, keyLength }) => {
renderHook(() => useAddPasswordOperation());
const callArgs = getToolConfig();
const buildFormData = callArgs.buildFormData;
const testParameters: AddPasswordFullParameters = {
password,
ownerPassword,
keyLength,
permissions: {
preventAssembly: false,
preventExtractContent: false,
preventExtractForAccessibility: false,
preventFillInForm: false,
preventModify: false,
preventModifyAnnotations: false,
preventPrinting: false,
preventPrintingFaithful: false
}
};
const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
const formData = buildFormData(testParameters, testFile as any /* FIX ME */);
// Verify the form data contains the file
expect(formData.get('fileInput')).toBe(testFile);
// Verify password parameters
expect(formData.get('password')).toBe(password);
expect(formData.get('ownerPassword')).toBe(ownerPassword);
expect(formData.get('keyLength')).toBe(keyLength.toString());
});
test('should use correct translation for error messages', () => {
renderHook(() => useAddPasswordOperation());
expect(mockT).toHaveBeenCalledWith(
'addPassword.error.failed',
'An error occurred while encrypting the PDF.'
);
});
test.each([
{ property: 'multiFileEndpoint' as const, expectedValue: false },
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' },
{ property: 'filePrefix' as const, expectedValue: 'translated-addPassword.filenamePrefix_' },
{ property: 'operationType' as const, expectedValue: 'addPassword' }
])('should configure $property correctly', ({ property, expectedValue }) => {
renderHook(() => useAddPasswordOperation());
const callArgs = getToolConfig();
expect(callArgs[property]).toBe(expectedValue);
});
});
@@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { AddPasswordFullParameters } from './useAddPasswordParameters';
import { getFormData } from '../changePermissions/useChangePermissionsOperation';
export const useAddPasswordOperation = () => {
const { t } = useTranslation();
const buildFormData = (parameters: AddPasswordFullParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("password", parameters.password);
formData.append("ownerPassword", parameters.ownerPassword);
formData.append("keyLength", parameters.keyLength.toString());
getFormData(parameters.permissions).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
return useToolOperation<AddPasswordFullParameters>({
operationType: 'addPassword',
endpoint: '/api/v1/security/add-password',
buildFormData,
filePrefix: t('addPassword.filenamePrefix', 'encrypted') + '_',
multiFileEndpoint: false,
getErrorMessage: createStandardErrorHandler(t('addPassword.error.failed', 'An error occurred while encrypting the PDF.'))
});
};
@@ -0,0 +1,152 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAddPasswordParameters, defaultParameters, AddPasswordParametersHook } from './useAddPasswordParameters';
import { defaultParameters as defaultChangePermissionsParameters, ChangePermissionsParameters } from '../changePermissions/useChangePermissionsParameters';
describe('useAddPasswordParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useAddPasswordParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test.each([
{ paramName: 'password' as const, value: 'test-password' },
{ paramName: 'ownerPassword' as const, value: 'owner-password' },
{ paramName: 'keyLength' as const, value: 256 }
])('should update parameter $paramName', ({ paramName, value }) => {
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toBe(value);
});
test.each([
{ paramName: 'preventAssembly' as const },
{ paramName: 'preventPrinting' as const }
])('should update boolean permission parameter $paramName', ({ paramName }) => {
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.permissions.updateParameter(paramName, true);
});
expect(result.current.permissions.parameters[paramName]).toBe(true);
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useAddPasswordParameters());
// First, change some parameters
act(() => {
result.current.updateParameter('password', 'test');
result.current.updateParameter('keyLength', 256);
result.current.permissions.updateParameter('preventAssembly', true);
});
expect(result.current.parameters.password).toBe('test');
expect(result.current.parameters.keyLength).toBe(256);
expect(result.current.permissions.parameters.preventAssembly).toBe(true);
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useAddPasswordParameters());
expect(result.current.getEndpointName()).toBe('add-password');
});
test.each([
{
description: 'with user password only',
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter('password', 'user-password');
}
},
{
description: 'with owner password only',
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter('ownerPassword', 'owner-password');
}
},
{
description: 'with both passwords',
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter('password', 'user-password');
hook.updateParameter('ownerPassword', 'owner-password');
}
},
{
description: 'with whitespace only password',
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter('password', ' \t ');
}
},
{
description: 'with whitespace only owner password',
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter('ownerPassword', ' \t ');
}
},
{
description: 'with restrictions only',
setup: (hook: AddPasswordParametersHook) => {
hook.permissions.updateParameter('preventAssembly', true);
hook.permissions.updateParameter('preventPrinting', true);
}
},
{
description: 'with passwords and restrictions',
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter('password', 'test-password');
hook.permissions.updateParameter('preventAssembly', true);
}
}
])('should validate parameters correctly $description', ({ setup }) => {
const { result } = renderHook(() => useAddPasswordParameters());
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
// Apply the test scenario setup
act(() => {
setup(result.current);
});
expect(result.current.validateParameters()).toBe(true);
});
test.each(Object.keys(defaultChangePermissionsParameters) as Array<keyof ChangePermissionsParameters>)('should handle boolean restriction parameter %s', (param) => {
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.resetParameters();
result.current.permissions.updateParameter(param, true);
});
expect(result.current.validateParameters()).toBe(true);
});
test('should handle mixed parameter types in updateParameter', () => {
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.updateParameter('password', 'test-string');
result.current.updateParameter('keyLength', 40);
result.current.permissions.updateParameter('preventAssembly', true);
});
expect(result.current.parameters.password).toBe('test-string');
expect(result.current.parameters.keyLength).toBe(40);
expect(result.current.permissions.parameters.preventAssembly).toBe(true);
});
});
@@ -0,0 +1,69 @@
import { useState } from 'react';
import { ChangePermissionsParameters, ChangePermissionsParametersHook, useChangePermissionsParameters } from '../changePermissions/useChangePermissionsParameters';
export interface AddPasswordParameters {
password: string;
ownerPassword: string;
keyLength: number;
}
export interface AddPasswordFullParameters extends AddPasswordParameters {
permissions: ChangePermissionsParameters;
}
export interface AddPasswordParametersHook {
fullParameters: AddPasswordFullParameters;
parameters: AddPasswordParameters;
permissions: ChangePermissionsParametersHook;
updateParameter: <K extends keyof AddPasswordParameters>(parameter: K, value: AddPasswordParameters[K]) => void;
resetParameters: () => void;
validateParameters: () => boolean;
getEndpointName: () => string;
}
export const defaultParameters: AddPasswordParameters = {
password: '',
ownerPassword: '',
keyLength: 128,
};
export const useAddPasswordParameters = (): AddPasswordParametersHook => {
const [parameters, setParameters] = useState<AddPasswordParameters>(defaultParameters);
const permissions = useChangePermissionsParameters();
const fullParameters: AddPasswordFullParameters = {
...parameters,
permissions: permissions.parameters,
};
const updateParameter = <K extends keyof AddPasswordParameters>(parameter: K, value: AddPasswordParameters[K]) => {
setParameters(prev => ({
...prev,
[parameter]: value,
})
);
};
const resetParameters = () => {
setParameters(defaultParameters);
permissions.resetParameters();
};
const validateParameters = () => {
// No required parameters for Add Password. Defer to permissions validation.
return permissions.validateParameters();
};
const getEndpointName = () => {
return 'add-password';
};
return {
fullParameters,
parameters,
permissions,
updateParameter,
resetParameters,
validateParameters,
getEndpointName,
};
};