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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/composables/workflows/useStepExecutions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,10 @@ export function useStepExecutions() {
const apiEvidence = evidence?.map((ev) => {
// Determine media type based on evidence type or file
let mediaType = 'application/octet-stream';
if (ev.evidenceType === 'screenshot') {
mediaType = 'image/png';
} else if (ev.file?.type) {
if (ev.file?.type) {
mediaType = ev.file.type;
} else if (ev.evidenceType === 'screenshot') {
mediaType = 'image/png';
} else if (ev.fileName) {
// Guess from extension
const ext = ev.fileName.split('.').pop()?.toLowerCase();
Comment on lines 148 to 156
Expand All @@ -160,6 +160,7 @@ export function useStepExecutions() {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
txt: 'text/plain',
json: 'application/json',
xml: 'application/xml',
Expand Down
66 changes: 61 additions & 5 deletions src/views/workflow-executions/partials/EvidenceSubmissionForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,11 @@
type="file"
multiple
@change="handleFileChange"
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.gif"
:accept="fileAccept"
class="w-full px-3 py-2 border border-ccf-300 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900 text-gray-900 dark:text-slate-200"
/>
<small class="text-gray-500 dark:text-slate-400">
Supported: PDF, Word, Images (max 10MB per file, multiple files
allowed)
{{ fileHelpText }}
</small>
<div v-if="selectedFiles.length > 0" class="mt-2 space-y-1">
<div class="text-sm text-green-600 dark:text-green-400">
Expand Down Expand Up @@ -102,7 +101,7 @@
</template>

<script setup lang="ts">
import { ref, computed } from 'vue';
import { ref, computed, watch } from 'vue';
import type {
StepExecution,
EvidenceType,
Expand Down Expand Up @@ -140,6 +139,21 @@ const selectedFiles = ref<File[]>([]);
const isSubmitting = ref(false);
const submitError = ref('');

const fileTypesByEvidenceType: Partial<
Record<EvidenceType, { accept: string; label: string; extensions: string[] }>
> = {
document: {
accept: '.pdf,.doc,.docx,.jpg,.jpeg,.png,.gif,.webp',
label: 'PDF, Word, Images',
extensions: ['pdf', 'doc', 'docx', 'jpg', 'jpeg', 'png', 'gif', 'webp'],
},
screenshot: {
accept: '.png,.jpg,.jpeg,.gif,.webp',
label: 'PNG, JPG, JPEG, GIF, WebP',
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'],
},
};

// Get evidence types from requirements, or allow all common types
const availableEvidenceTypes = computed(() => {
const allTypes: EvidenceType[] = [
Expand All @@ -164,6 +178,18 @@ const isFileEvidenceType = computed(() => {
);
});

const fileTypeConfig = computed(() => {
if (!evidenceForm.value.type) return undefined;
return fileTypesByEvidenceType[evidenceForm.value.type];
});

const fileAccept = computed(() => fileTypeConfig.value?.accept ?? '');

const fileHelpText = computed(() => {
const label = fileTypeConfig.value?.label ?? 'Files';
return `Supported: ${label} (max 10MB per file, multiple files allowed)`;
});

const isAttestationEvidenceType = computed(() => {
return (
evidenceForm.value.type === 'attestation' ||
Expand All @@ -178,7 +204,11 @@ const isLinkEvidenceType = computed(() => {
const canSubmit = computed(() => {
if (!evidenceForm.value.type) return false;

if (isFileEvidenceType.value && selectedFiles.value.length === 0)
if (
isFileEvidenceType.value &&
(selectedFiles.value.length === 0 ||
selectedFiles.value.some((file) => !isAllowedFileType(file)))
)
return false;
if (
isAttestationEvidenceType.value &&
Expand All @@ -191,6 +221,16 @@ const canSubmit = computed(() => {
return true;
});

watch(
() => evidenceForm.value.type,
() => {
selectedFiles.value = [];
submitError.value = '';
const fileInput = document.getElementById('file') as HTMLInputElement;
if (fileInput) fileInput.value = '';
},
Comment on lines +224 to +231
);

function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
Expand All @@ -204,11 +244,27 @@ function handleFileChange(event: Event) {
return;
}

const invalidFiles = files.filter((file) => !isAllowedFileType(file));
if (invalidFiles.length > 0) {
submitError.value = `The following files are not supported for ${evidenceForm.value.type} evidence: ${invalidFiles.map((f) => f.name).join(', ')}`;
selectedFiles.value = [];
target.value = '';
return;
}

selectedFiles.value = files;
submitError.value = '';
}
}

function isAllowedFileType(file: File): boolean {
const config = fileTypeConfig.value;
if (!config) return false;

const ext = file.name.split('.').pop()?.toLowerCase();
return !!ext && config.extensions.includes(ext);
Comment on lines +260 to +265
}

function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import EvidenceSubmissionForm from '../EvidenceSubmissionForm.vue';
import type { StepExecution } from '@/types/workflows';

const step = {
id: 'step-1',
status: 'in_progress',
workflowExecutionId: 'exec-1',
workflowStepDefinitionId: 'def-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as StepExecution;

function mountComponent(evidenceRequirements: string[] = []) {
return mount(EvidenceSubmissionForm, {
props: {
step,
evidenceRequirements,
},
global: {
stubs: {
Label: { template: '<label><slot /></label>' },
Select: {
props: ['modelValue', 'options'],
emits: ['update:modelValue'],
template:
'<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value)"><option value=""></option><option v-for="option in options" :key="option" :value="option">{{ option }}</option></select>',
},
InputText: { template: '<input />' },
Textarea: { template: '<textarea />' },
PrimaryButton: {
props: ['disabled'],
template: '<button :disabled="disabled"><slot /></button>',
},
Message: { template: '<div role="alert"><slot /></div>' },
},
},
});
}

async function selectEvidenceType(
wrapper: ReturnType<typeof mountComponent>,
type: string,
) {
await wrapper.find('select').setValue(type);
}

async function uploadFiles(
wrapper: ReturnType<typeof mountComponent>,
files: File[],
) {
const input = wrapper.find('input[type="file"]');
Object.defineProperty(input.element, 'files', {
configurable: true,
value: files,
});
await input.trigger('change');
}

describe('EvidenceSubmissionForm file validation', () => {
it('uses image-only accept values for screenshot evidence', async () => {
const wrapper = mountComponent(['screenshot', 'document']);

await selectEvidenceType(wrapper, 'screenshot');

expect(wrapper.find('input[type="file"]').attributes('accept')).toBe(
'.png,.jpg,.jpeg,.gif,.webp',
);
expect(wrapper.text()).toContain('Supported: PNG, JPG, JPEG, GIF, WebP');
});

it('keeps document evidence support for documents and images', async () => {
const wrapper = mountComponent(['document']);

await selectEvidenceType(wrapper, 'document');

expect(wrapper.find('input[type="file"]').attributes('accept')).toBe(
'.pdf,.doc,.docx,.jpg,.jpeg,.png,.gif,.webp',
);
expect(wrapper.text()).toContain('Supported: PDF, Word, Images');
});

it('rejects document files selected for screenshot evidence', async () => {
const wrapper = mountComponent(['screenshot']);

await selectEvidenceType(wrapper, 'screenshot');
await uploadFiles(wrapper, [
new File(['content'], 'evidence.pdf', { type: 'application/pdf' }),
]);

expect(wrapper.text()).toContain(
'not supported for screenshot evidence: evidence.pdf',
);
expect(wrapper.emitted('evidence-submitted')).toBeUndefined();
expect(wrapper.find('button').attributes('disabled')).toBeDefined();
});

it('accepts webp files for screenshot evidence', async () => {
const wrapper = mountComponent(['screenshot']);

await selectEvidenceType(wrapper, 'screenshot');
await uploadFiles(wrapper, [
new File(['content'], 'evidence.webp', { type: 'image/webp' }),
]);

expect(wrapper.text()).toContain('1 file(s) selected');
expect(wrapper.find('button').attributes('disabled')).toBeUndefined();
});
});
Loading