From fc33ec673e722637168e2d5247ea571853a083eb Mon Sep 17 00:00:00 2001 From: Scott Horton Date: Sun, 4 Oct 2026 10:26:09 -0500 Subject: [PATCH 1/2] Fix checkbox defaults with checked and unchecked selector --- CHANGELOG.md | 4 ++++ frontend/src/main.tsx | 26 ++++++++++++++++++-------- package-lock.json | 4 ++-- package.json | 2 +- splunk_actionstack/default/app.conf | 2 +- tests/test_field_catalog.py | 14 ++++++++++++++ 6 files changed, 40 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 824b2bb..1327158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.5.8 + +- Replace the checkbox default text input with a Checked / Unchecked selector so both boolean defaults can be set reliably. + ## 0.5.7 - Make catalog Open form controls compact buttons with keyboard focus styling. In dark mode, use a dark iridescent fill that lights up on hover or keyboard focus, with a shine animation on hover. diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d650f23..3907106 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2525,7 +2525,23 @@ function Builder({ ))} )} + {field.type === "checkbox" && ( + + )} {![ + "checkbox", "multiselect", "section", "static_text", @@ -2547,17 +2563,11 @@ function Builder({ ? undefined : field.type === "number" ? Number(e.target.value) - : field.type === "checkbox" - ? e.target.value === "true" - : e.target.value, + : e.target.value, }) } /> - - {field.type === "checkbox" - ? "Use true or false." - : "Optional initial value."} - + Optional initial value. )} diff --git a/package-lock.json b/package-lock.json index 64c2b09..820156e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "splunk-actionstack", - "version": "0.5.7", + "version": "0.5.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "splunk-actionstack", - "version": "0.5.7", + "version": "0.5.8", "dependencies": { "lucide-react": "^0.577.0", "react": "^19.2.0", diff --git a/package.json b/package.json index bc6be8e..9941448 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "splunk-actionstack", - "version": "0.5.7", + "version": "0.5.8", "private": true, "type": "module", "scripts": { diff --git a/splunk_actionstack/default/app.conf b/splunk_actionstack/default/app.conf index 6eb52d6..45922e9 100644 --- a/splunk_actionstack/default/app.conf +++ b/splunk_actionstack/default/app.conf @@ -8,7 +8,7 @@ label = ActionStack [launcher] author = ActionStack -version = 0.5.7 +version = 0.5.8 description = Role-aware forms and reliable event submission to Splunk SOAR. [package] diff --git a/tests/test_field_catalog.py b/tests/test_field_catalog.py index 10de502..6653783 100644 --- a/tests/test_field_catalog.py +++ b/tests/test_field_catalog.py @@ -58,6 +58,20 @@ def test_arrays_reach_soar_as_structured_inputs(self): class LifecycleTests(unittest.TestCase): setUp=fx.ServiceTests.setUp tearDown=fx.ServiceTests.tearDown + def test_checkbox_defaults_survive_publish_and_submission(self): + revision=1 + for default in [True,False]: + with self.subTest(default=default): + field=dict(simple_field('checkbox'),required=False,default=default) + saved=self.svc.save_form(fx.ADMIN,{'form':definition(field),'expected_revision':revision},True) + revision=saved['revision'] + self.assertIs(self.svc.published('block-object')['fields'][0]['default'],default) + for incoming,expected in [({},default),({'target':not default},not default)]: + receipt=self.svc.submit(self.user,{'form_id':'block-object','form_version':saved['version'],'inputs':incoming,'idempotency_key':f'checkbox-{default}-{expected}'}) + record=self.store.get('submissions',receipt['id']) + self.assertEqual(record['status'],'submitted') + self.assertIs(record['artifact_payload']['data']['actionstack']['inputs']['target'],expected) + def test_editor_migration_is_detached_and_preserves_legacy_behavior(self): old=self.svc.published('block-object') f=self.svc.dispatch(fx.ADMIN,'GET','/admin/forms')[0] From 5ffb2d218ee82cfc0eb945589c710ea094de4ade Mon Sep 17 00:00:00 2001 From: Scott Horton Date: Sun, 4 Oct 2026 10:40:56 -0500 Subject: [PATCH 2/2] Add workspace category selection and form import/export --- CHANGELOG.md | 5 + README.md | 9 ++ frontend/src/CategoryPicker.tsx | 57 +++++++++ frontend/src/ImportForm.tsx | 117 ++++++++++++++++++ frontend/src/form-transfer.js | 40 ++++++ frontend/src/main.tsx | 99 +++++++++++++-- frontend/src/styles.css | 5 + package-lock.json | 4 +- package.json | 2 +- .../bin/actionstack/form_transfer.py | 42 +++++++ splunk_actionstack/bin/actionstack/service.py | 3 + splunk_actionstack/default/app.conf | 2 +- tests/form-transfer.test.mjs | 20 +++ tests/test_form_transfer.py | 78 ++++++++++++ 14 files changed, 468 insertions(+), 15 deletions(-) create mode 100644 frontend/src/CategoryPicker.tsx create mode 100644 frontend/src/ImportForm.tsx create mode 100644 frontend/src/form-transfer.js create mode 100644 splunk_actionstack/bin/actionstack/form_transfer.py create mode 100644 tests/form-transfer.test.mjs create mode 100644 tests/test_form_transfer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1327158..85425fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.5.9 + +- Select existing workspace categories or add a new category in form settings. +- Export portable form JSON from the list or editor. Import validated files as new, unsaved drafts using the destination workspace’s access defaults. Existing forms, submissions, and connection settings are preserved. + ## 0.5.8 - Replace the checkbox default text input with a Checked / Unchecked selector so both boolean defaults can be set reliably. diff --git a/README.md b/README.md index 8cad513..b1360d0 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ ActionStack is an AI-driven project attempting to build something useful for the - Automation catalog with search, categories, favorites, and pagination. - Form builder with conditional fields, validation, lookup inputs, multiple-value inputs, and collapsible sections. - Drafts, publishing, version history, cloning, and recoverable form deletion. +- Reusable workspace categories and JSON form export/import. - Configurable SOAR labels, tags, CEF mappings, and approval requirements handled by your playbooks. - Paginated submission history with playbook/action status counts, delivery retries, and receipts with custom action names, reported block results, summaries, and data. - Optional receipt timeline and JSON receipt / CSV submission exports for audit review. @@ -33,6 +34,14 @@ Open ActionStack as a Splunk administrator. The setup wizard creates the first w See [Deployment](DEPLOYMENT.md) for configuration and permissions, and the [SOAR event contract](SOAR_EVENT_CONTRACT.md) for submitted fields. +## Moving forms between workspaces or instances + +Use **Export** in the form list, or **Export form** in the editor, to download a JSON definition. The editor export includes unsaved edits. Exports include fields, defaults, validation, conditional sections, appearance, category, lookup configuration, and SOAR mapping. They exclude source workspace permissions, form IDs, version history, submissions, and connection credentials. + +Choose **Import form**, select the JSON file (one form, up to 200 KB), and choose a destination workspace. ActionStack validates the file and opens a new, unsaved draft with a new ID and the destination workspace's default access. Review SOAR labels and lookup sources before saving or publishing; they must exist on the destination instance. Importing does not overwrite an existing form or create a SOAR event. + +In the editor's **Settings** tab, **Category** lists categories used by accessible forms in the current workspace. Choose **Add a new category…** to create another; it becomes reusable when the form is saved. + ## Development Requires Node.js 22 and Python 3.9 or 3.13. diff --git a/frontend/src/CategoryPicker.tsx b/frontend/src/CategoryPicker.tsx new file mode 100644 index 0000000..bb809e9 --- /dev/null +++ b/frontend/src/CategoryPicker.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; + +export function CategoryPicker({ + value, + categories, + onChange, +}: { + value: string; + categories: string[]; + onChange: (value: string) => void; +}) { + const [adding, setAdding] = useState(false); + const choices = Array.from( + new Set( + ["General", ...categories, value].map((c) => c.trim()).filter(Boolean), + ), + ).sort((a, b) => a.localeCompare(b)); + return ( +
+ + {adding && ( + + )} +
+ ); +} diff --git a/frontend/src/ImportForm.tsx b/frontend/src/ImportForm.tsx new file mode 100644 index 0000000..d8cd066 --- /dev/null +++ b/frontend/src/ImportForm.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import { api } from "./api"; +import type { Form, Workspace } from "./types"; +import { MAX_FORM_FILE_BYTES, parseFormFile } from "./form-transfer.js"; + +export function ImportForm({ + workspaces, + workspaceId, + onImported, + onCancel, +}: { + workspaces: Workspace[]; + workspaceId: string; + onImported: (form: Form) => void; + onCancel: () => void; +}) { + const [destination, setDestination] = useState( + workspaces.some((w) => w.id === workspaceId) + ? workspaceId + : workspaces[0]?.id || "", + ); + const [file, setFile] = useState(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + return ( +
{ + e.preventDefault(); + if (!file || !destination) return; + setBusy(true); + setError(""); + try { + if (file.size > MAX_FORM_FILE_BYTES) + throw new Error( + "Choose an ActionStack form JSON file under 200 KB.", + ); + const document = parseFormFile(await file.text()); + const form = await api("/admin/forms/import", { + document, + workspace_id: destination, + }); + onImported(form); + } catch (err) { + setError((err as Error).message); + } finally { + setBusy(false); + } + }} + > +
+

+ Open an exported form as a new draft. Review its settings, then save + or publish. +

+ + +

+ The workspace’s default access applies. SOAR mapping and lookup + searches are included; check that their labels, apps, and lookups + exist here. Existing forms are kept. +

+ {error && ( +
+ {error} +
+ )} +
+
+ + +
+
+ ); +} diff --git a/frontend/src/form-transfer.js b/frontend/src/form-transfer.js new file mode 100644 index 0000000..161fcb2 --- /dev/null +++ b/frontend/src/form-transfer.js @@ -0,0 +1,40 @@ +import { cloneDefinition } from "./browser-compat.js"; + +export const MAX_FORM_FILE_BYTES = 200 * 1024; + +/** Export a portable definition, excluding environment-specific access and history. + * @param {import('./types').Form} form @param {string} appVersion */ +export function formExport(form, appVersion) { + const { title, description, category, icon, accent, intro, fields, mapping } = + form; + return { + format: "actionstack-form", + format_version: 1, + app_version: appVersion, + form: cloneDefinition({ + title, + description, + category, + icon, + accent, + intro, + fields, + mapping, + }), + }; +} + +/** @param {string} text @returns {unknown} */ +export function parseFormFile(text) { + if (new TextEncoder().encode(text).length > MAX_FORM_FILE_BYTES) + throw new Error("Choose an ActionStack form JSON file under 200 KB."); + let data; + try { + data = JSON.parse(text); + } catch { + throw new Error("This file is not valid JSON."); + } + if (!data || data.format !== "actionstack-form" || data.format_version !== 1) + throw new Error("Choose an ActionStack form export (format version 1)."); + return data; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 3907106..e29f40b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,6 +8,7 @@ import React, { useEffect, useState, useRef } from "react"; import { createRoot } from "react-dom/client"; import { Download, + Upload, ArrowUpRight, ArrowRight, ArrowLeft, @@ -67,6 +68,9 @@ import { formIcon } from "./FormIcons"; import { FormAppearance, ThemePicker } from "./Appearance"; import { FormSections } from "./FormSections"; import { cloneForm } from "./clone-form.js"; +import { CategoryPicker } from "./CategoryPicker"; +import { ImportForm } from "./ImportForm"; +import { formExport, MAX_FORM_FILE_BYTES } from "./form-transfer.js"; import { ApprovalRules } from "./ApprovalRules"; import { Disclosure } from "./Disclosure"; import { RunCounts } from "./RunCounts"; @@ -1104,6 +1108,7 @@ function App() { )} {page === "builder" && ( void; }) { const [automaticId, setAutomaticId] = useState(false); + const [importing, setImporting] = useState(false); const [labels, setLabels] = useState<{ prefix: string; labels: string[] }>({ prefix: "", labels: [], @@ -1731,6 +1739,44 @@ function Builder({ "Cloned as a new draft. Review the settings, then save or publish.", ); } + function exportForm(form: Form) { + setError(""); + try { + const contents = JSON.stringify(formExport(form, appVersion), null, 2); + if (new TextEncoder().encode(contents).length > MAX_FORM_FILE_BYTES) + throw new Error("This form exceeds the 200 KB export limit."); + downloadAudit( + contents, + `actionstack-form-${form.id}.json`, + "application/json", + ); + } catch (e) { + setError((e as Error).message); + } + } + const importDialog = importing && ( + setImporting(false)}> + + w.state === "active" && + (!w.role_groups || + can(ctx, "admin") || + w.role_groups.admin.some((r) => ctx.roles.includes(r))), + )} + onCancel={() => setImporting(false)} + onImported={(form) => { + start(form); + setAutomaticId(true); + setImporting(false); + setNotice( + "Imported as a new draft. Review workspace access, SOAR mapping, and lookup sources, then save or publish.", + ); + }} + /> + + ); function update(patch: Partial
) { if ( editor && @@ -1832,10 +1878,16 @@ function Builder({

Form builder

Turn a team's request into a repeatable starting point.

- +
+ + +
@@ -1904,6 +1956,16 @@ function Builder({ v{f.version} {f.updated_at ? formatDate(f.updated_at) : "—"} + {!trash && ( + + )} {!trash && (
+ {editor.revision > 0 && (
)} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9782d7d..10dcae6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1489,6 +1489,11 @@ justify-content: space-between; gap: 20px; margin-bottom: 24px; + flex-wrap: wrap; +} +.editor-heading .button-row, +.page-heading .button-row { + flex-wrap: wrap; } .editor-heading h1 { display: flex; diff --git a/package-lock.json b/package-lock.json index 820156e..f7c14c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "splunk-actionstack", - "version": "0.5.8", + "version": "0.5.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "splunk-actionstack", - "version": "0.5.8", + "version": "0.5.9", "dependencies": { "lucide-react": "^0.577.0", "react": "^19.2.0", diff --git a/package.json b/package.json index 9941448..d153c5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "splunk-actionstack", - "version": "0.5.8", + "version": "0.5.9", "private": true, "type": "module", "scripts": { diff --git a/splunk_actionstack/bin/actionstack/form_transfer.py b/splunk_actionstack/bin/actionstack/form_transfer.py new file mode 100644 index 0000000..50c622c --- /dev/null +++ b/splunk_actionstack/bin/actionstack/form_transfer.py @@ -0,0 +1,42 @@ +"""Validate portable form files without saving or running their lookups.""" +import json +import re +import uuid +from .core import Error, capable, require, clone, validate_definition + +PORTABLE_FIELDS={'title','description','category','icon','accent','intro','fields','mapping'} + +def import_form(service, actor, body): + require(actor,'edit') + if not isinstance(body,dict) or set(body)!={'document','workspace_id'} or not isinstance(body['workspace_id'],str): + raise Error(400,'Choose a form file and destination workspace.') + workspace=service.workspace_records().get(body['workspace_id']) + if not workspace or not service.workspace_allowed(actor,body['workspace_id']): + raise Error(403,'This workspace is unavailable.') + if workspace.get('role_groups') and not capable(actor,'admin') and not set(workspace['role_groups']['admin'])&set(actor['roles']): + raise Error(403,'Only workspace admins can create forms in this workspace.') + document=body['document'] + if not isinstance(document,dict) or set(document)-{'format','format_version','app_version','form'} or document.get('format')!='actionstack-form' or type(document.get('format_version')) is not int or document['format_version']!=1: + raise Error(400,'Choose an ActionStack form export (format version 1).') + try: + if len(json.dumps(document,ensure_ascii=False,allow_nan=False).encode('utf-8'))>200*1024: + raise Error(400,'Choose an ActionStack form JSON file under 200 KB.') + raw=document.get('form') + if not isinstance(raw,dict) or set(raw)-PORTABLE_FIELDS: + raise Error(400,'The form file contains unsupported properties.') + f=clone(raw) + title=f.get('title','') + if not isinstance(title,str): raise Error(400,'Enter a valid form title.') + slug=re.sub('[^a-z0-9]+','-',title.lower()).strip('-') + slug=re.sub('^[^a-z]+','',slug)[:48].rstrip('-') or 'form' + f['id']=slug+'-'+uuid.uuid4().hex[:4] + f['workspace_id']=workspace['id'] + f['access']=clone(workspace.get('default_access') or {'view_roles':[],'submit_roles':[],'edit_roles':actor['roles'],'team_roles':[]}) + f=validate_definition(f,service.roles()) + # Portable files contain inline validation, never policy IDs from another instance. + if f['mapping'].get('policy') not in [None,'none']: + raise Error(400,'Export this form again from the current form builder to include its field validation.') + except (TypeError,ValueError,KeyError,AttributeError,RecursionError): + raise Error(400,'The form file has an invalid structure. Export it again from ActionStack.') + f.update(revision=0,version=0,state='draft') + return f diff --git a/splunk_actionstack/bin/actionstack/service.py b/splunk_actionstack/bin/actionstack/service.py index 68b74cd..2c3b844 100644 --- a/splunk_actionstack/bin/actionstack/service.py +++ b/splunk_actionstack/bin/actionstack/service.py @@ -426,6 +426,9 @@ def dispatch(self,actor,method,path,body=None): if path=='/preferences' and method in ['GET','POST']: return self.preferences(actor,body if method=='POST' else None) if path=='/admin/soar-labels' and method=='GET': return self.available_labels(actor) if path=='/admin/forms/validate' and method=='POST': return self.preview_validation(actor,body) + if path=='/admin/forms/import' and method=='POST': + from .form_transfer import import_form + return import_form(self,actor,body) if path=='/submission-fingerprint' and method=='POST': return self.submission_fingerprint(actor,body) if path=='/workspaces' and method=='GET': return self.list_workspaces(actor) if path=='/admin/workspaces' and method=='GET': return self.list_workspaces(actor,True) diff --git a/splunk_actionstack/default/app.conf b/splunk_actionstack/default/app.conf index 45922e9..8b40f62 100644 --- a/splunk_actionstack/default/app.conf +++ b/splunk_actionstack/default/app.conf @@ -8,7 +8,7 @@ label = ActionStack [launcher] author = ActionStack -version = 0.5.8 +version = 0.5.9 description = Role-aware forms and reliable event submission to Splunk SOAR. [package] diff --git a/tests/form-transfer.test.mjs b/tests/form-transfer.test.mjs new file mode 100644 index 0000000..b56736e --- /dev/null +++ b/tests/form-transfer.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { formExport, parseFormFile, MAX_FORM_FILE_BYTES } from '../frontend/src/form-transfer.js'; + +test('portable exports preserve configuration without source identity, access, or history', () => { + const source = { id:'source', workspace_id:'hr', title:'Access request', category:'People', description:'Help', intro:'Start here', icon:'user', accent:'cyan', fields:[{ key:'enabled', type:'checkbox', label:'Enabled', default:true }], mapping:{ label:'actionstack_hr', run_automation:true }, access:{edit_roles:['source_admin']}, revision:8, version:4, state:'published', updated_by:'alice', validation_policy_id:'local-policy', connection:{token:'secret'} }; + const result=formExport(source,'0.5.9'); + assert.deepEqual(Object.keys(result.form).sort(),['title','description','category','icon','accent','intro','fields','mapping'].sort()); + assert.deepEqual(parseFormFile(JSON.stringify(result)),result); + assert.equal(result.form.fields[0].default,true); + assert.equal(result.form.mapping.run_automation,true); + assert.ok(!JSON.stringify(result).includes('secret')); + result.form.fields[0].default=false; + assert.equal(source.fields[0].default,true); +}); + +test('import file parser rejects bad JSON, unsupported formats, and oversized UTF-8 files', () => { + for (const input of ['{', 'null', '[]', '{}', JSON.stringify({format:'actionstack-form',format_version:2}), JSON.stringify({format:'actionstack-form',format_version:true})]) assert.throws(()=>parseFormFile(input)); + assert.throws(()=>parseFormFile('é'.repeat(MAX_FORM_FILE_BYTES/2+1)),/200 KB/); +}); diff --git a/tests/test_form_transfer.py b/tests/test_form_transfer.py new file mode 100644 index 0000000..5f0a993 --- /dev/null +++ b/tests/test_form_transfer.py @@ -0,0 +1,78 @@ +import unittest +import test_pages as fx +from actionstack.core import Error, clone +from actionstack.form_transfer import PORTABLE_FIELDS + +class FormTransferTests(unittest.TestCase): + setUp=fx.ServiceTests.setUp + tearDown=fx.ServiceTests.tearDown + + def body(self): + source=self.svc.dispatch(fx.ADMIN,'GET','/admin/forms')[0] + return {'workspace_id':'security','document':{'format':'actionstack-form','format_version':1,'app_version':'0.5.9','form':{k:clone(v) for k,v in source.items() if k in PORTABLE_FIELDS}}} + + def imported(self,body,actor=fx.ADMIN): + return self.svc.dispatch(actor,'POST','/admin/forms/import',body) + + def test_import_preserves_configuration_as_unsaved_draft_with_new_identity(self): + body=self.body(); source=clone(body['document']['form']); forms=clone(self.store.list('forms')) + self.factory.reset_mock() + result=self.imported(body) + self.assertNotEqual(result['id'],'block-object') + self.assertRegex(result['id'],r'^block-an-object-[a-f0-9]{4}$') + self.assertEqual((result['state'],result['revision'],result['version']),('draft',0,0)) + for key in ['fields','mapping','category','icon','accent']: self.assertEqual(result[key],source[key]) + self.assertEqual(result['access'],{'view_roles':[],'submit_roles':[],'edit_roles':['admin'],'team_roles':[]}) + self.assertEqual(self.store.list('forms'),forms) + self.assertEqual(self.store.list('submissions'),[]) + self.factory.assert_not_called() + definition={k:v for k,v in result.items() if k not in ['revision','version','state']} + saved=self.svc.save_form(fx.ADMIN,{'form':definition,'expected_revision':0}) + self.assertEqual(saved['state'],'draft') + self.assertEqual(self.svc.published('block-object')['id'],'block-object') + + def test_destination_defaults_and_create_permissions_are_enforced(self): + body=self.body() + self.svc.save_workspace(fx.ADMIN,{'workspace':{'id':'hr','name':'HR','description':'','roles':['team_a'],'state':'active','role_groups':{'viewer':['team_a'],'user':['team_a'],'admin':['team_b']}},'expected_revision':0}) + body['workspace_id']='hr' + for actor in [self.user,fx.actor('editor',['team_a'],['use','edit'])]: + with self.assertRaises(Error) as denied:self.imported(body,actor) + self.assertEqual(denied.exception.status,403) + admin=fx.actor('hr_admin',['team_b'],['use','edit']) + result=self.imported(body,admin) + self.assertEqual(result['access'],self.svc.workspace_records()['hr']['default_access']) + body['workspace_id']='missing' + with self.assertRaises(Error):self.imported(body) + w=self.svc.workspace_records()['hr'] + w['state']='archived';self.store.put('workspaces',w) + body['workspace_id']='hr' + with self.assertRaises(Error):self.imported(body) + + def test_invalid_files_and_source_permissions_are_rejected(self): + base=self.body() + bad=[None,[],{},dict(base['document'],format_version=True),dict(base['document'],format_version=2),dict(base['document'],format='receipt')] + for field,value in [('access',{}),('workspace_id','foreign'),('id','block-object'),('revision',8),('validation_policy_id','missing')]: + document=clone(base['document']);document['form'][field]=value;bad.append(document) + for value in [None,[],{'fields':[]},dict(base['document']['form'],mapping=None),dict(base['document']['form'],fields=[{'key':'bad','type':[],'label':'Bad'}])]: + bad.append(dict(base['document'],form=value)) + bad.append(dict(base['document'],app_version='x'*210000)) + for document in bad: + with self.subTest(document=str(document)[:120]),self.assertRaises(Error) as rejected:self.imported(dict(base,document=document)) + self.assertEqual(rejected.exception.status,400) + self.assertEqual(len(self.store.list('forms')),1) + + def test_lookup_spl_and_conditional_fields_are_validated_without_running_search(self): + body=self.body();form=body['document']['form'] + form['mapping'].pop('enrichment',None);form['mapping'].pop('approval',None) + form['fields']=[{'key':'enabled','type':'checkbox','label':'Enabled','default':True},{'key':'user','type':'lookup','label':'User','show_when':{'field':'enabled','equals':True},'lookup':{'search':'| inputlookup identities | eval label=upper(identity) | table identity label','app':'search','value_field':'identity','label_field':'label','min_chars':3,'debounce_ms':50}}] + self.factory.reset_mock() + result=self.imported(body) + self.assertEqual(result['fields'],form['fields']) + self.factory.assert_not_called() + form['fields'][1]['lookup']['search']='| inputlookup identities | outputlookup stolen' + with self.assertRaises(Error):self.imported(body) + + def test_import_has_same_field_validation_as_form_save(self): + body=self.body() + body['document']['form']['fields'][0]['default']='invalid-choice' + with self.assertRaises(Error):self.imported(body)