fix: Argument validation targets the bindings (fixes checkboxes, etc) (#800)

This commit is contained in:
James Read 2025-12-23 19:57:57 +00:00 committed by GitHub
commit 365e52ae1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 296 additions and 11 deletions

View File

@ -1,4 +1,4 @@
// @generated by protoc-gen-es v2.10.1 // @generated by protoc-gen-es v2.10.2
// @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3) // @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
/* eslint-disable */ /* eslint-disable */
@ -686,6 +686,16 @@ export declare type ValidateArgumentTypeRequest = Message<"olivetin.api.v1.Valid
* @generated from field: string type = 2; * @generated from field: string type = 2;
*/ */
type: string; type: string;
/**
* @generated from field: string binding_id = 3;
*/
bindingId: string;
/**
* @generated from field: string argument_name = 4;
*/
argumentName: string;
}; };
/** /**

File diff suppressed because one or more lines are too long

View File

@ -116,8 +116,8 @@ async function setup() {
// Run initial validation on all fields after DOM is updated // Run initial validation on all fields after DOM is updated
await nextTick() await nextTick()
for (const arg of actionArguments.value) { for (const arg of actionArguments.value) {
if (arg.type && !arg.type.startsWith('regex:') && arg.type !== 'select' && arg.type !== '' && arg.type !== 'confirmation') { if (arg.type && !arg.type.startsWith('regex:') && arg.type !== 'select' && arg.type !== '' && arg.type !== 'confirmation' && arg.type !== 'checkbox') {
await validateArgument(arg, argValues.value[arg.name]) await validateArgument(arg, argValues.value[arg.name] || '')
} }
} }
} }
@ -212,10 +212,22 @@ async function validateArgument(arg, value) {
return return
} }
// Skip validation for checkbox and confirmation - they're always valid
if (arg.type === 'checkbox' || arg.type === 'confirmation') {
const inputElement = document.getElementById(arg.name)
if (inputElement) {
inputElement.setCustomValidity('')
}
delete formErrors.value[arg.name]
return
}
try { try {
const validateArgumentTypeArgs = { const validateArgumentTypeArgs = {
value: value, value: value,
type: arg.type type: arg.type,
bindingId: props.bindingId,
argumentName: arg.name
} }
const validation = await window.client.validateArgumentType(validateArgumentTypeArgs) const validation = await window.client.validateArgumentType(validateArgumentTypeArgs)

View File

@ -0,0 +1,103 @@
import { describe, it, before, after } from 'mocha'
import { expect } from 'chai'
import { By, Condition } from 'selenium-webdriver'
import {
getRootAndWait,
getActionButton,
takeScreenshotOnFailure,
} from '../../lib/elements.js'
describe('config: checkbox', function () {
before(async function () {
await runner.start('checkbox')
})
after(async () => {
await runner.stop()
})
afterEach(function () {
takeScreenshotOnFailure(this.currentTest, webdriver)
})
it('Checkbox argument is rendered as a checkbox input', async function () {
await getRootAndWait()
const btn = await getActionButton(webdriver, 'Test checkbox argument')
await btn.click()
// Wait for navigation to argument form page
await webdriver.wait(
new Condition('wait for argument form page', async () => {
const url = await webdriver.getCurrentUrl()
return url.includes('/actionBinding/') && url.includes('/argumentForm')
}),
8000
)
// Find the checkbox input field
const checkboxInput = await webdriver.findElement(By.id('confirm'))
// Verify it's an input of type checkbox
const tagName = await checkboxInput.getTagName()
expect(tagName).to.equal('input')
const inputType = await checkboxInput.getAttribute('type')
expect(inputType).to.equal('checkbox')
// Verify the label is present
const label = await webdriver.findElement(By.css('label[for="confirm"]'))
expect(await label.getText()).to.contain('Confirm option')
})
it('Checkbox argument can be toggled and submitted', async function () {
await getRootAndWait()
const btn = await getActionButton(webdriver, 'Test checkbox argument')
await btn.click()
// Wait for navigation to argument form page
await webdriver.wait(
new Condition('wait for argument form page', async () => {
const url = await webdriver.getCurrentUrl()
return url.includes('/actionBinding/') && url.includes('/argumentForm')
}),
8000
)
const checkboxInput = await webdriver.findElement(By.id('confirm'))
// Toggle the checkbox
await checkboxInput.click()
// Small wait for Vue to process the change
await webdriver.sleep(100)
// Verify the checkbox is checked
const isChecked = await checkboxInput.isSelected()
expect(isChecked).to.be.true
// Find and click the submit button
const submitButton = await webdriver.findElement(
By.css('button[name="start"]')
)
await submitButton.click()
// Wait for navigation to logs page
await webdriver.wait(
new Condition('wait for logs page', async () => {
const url = await webdriver.getCurrentUrl()
return url.includes('/logs/')
}),
8000
)
// Verify we're on the logs page (action was executed)
const url = await webdriver.getCurrentUrl()
expect(url).to.include('/logs/')
})
})

View File

@ -0,0 +1,18 @@
---
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
logLevel: "DEBUG"
checkForUpdates: false
actions:
- title: Test checkbox argument
shell: "echo 'Checkbox value: {{ confirm }}'"
icon: ping
arguments:
- name: confirm
title: Confirm option
type: checkbox
description: "When checked: 1, when unchecked: 0"
default: "false"

View File

@ -164,6 +164,8 @@ message GetActionLogsResponse {
message ValidateArgumentTypeRequest { message ValidateArgumentTypeRequest {
string value = 1; string value = 1;
string type = 2; string type = 2;
string binding_id = 3;
string argument_name = 4;
} }
message ValidateArgumentTypeResponse { message ValidateArgumentTypeResponse {

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.10 // protoc-gen-go v1.36.11
// protoc (unknown) // protoc (unknown)
// source: olivetin/api/v1/olivetin.proto // source: olivetin/api/v1/olivetin.proto
@ -1509,6 +1509,8 @@ type ValidateArgumentTypeRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"`
Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"`
BindingId string `protobuf:"bytes,3,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"`
ArgumentName string `protobuf:"bytes,4,opt,name=argument_name,json=argumentName,proto3" json:"argument_name,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -1557,6 +1559,20 @@ func (x *ValidateArgumentTypeRequest) GetType() string {
return "" return ""
} }
func (x *ValidateArgumentTypeRequest) GetBindingId() string {
if x != nil {
return x.BindingId
}
return ""
}
func (x *ValidateArgumentTypeRequest) GetArgumentName() string {
if x != nil {
return x.ArgumentName
}
return ""
}
type ValidateArgumentTypeResponse struct { type ValidateArgumentTypeResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"` Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"`
@ -3946,10 +3962,13 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
"\tpage_size\x18\x03 \x01(\x03R\bpageSize\x12\x1f\n" + "\tpage_size\x18\x03 \x01(\x03R\bpageSize\x12\x1f\n" +
"\vtotal_count\x18\x04 \x01(\x03R\n" + "\vtotal_count\x18\x04 \x01(\x03R\n" +
"totalCount\x12!\n" + "totalCount\x12!\n" +
"\fstart_offset\x18\x05 \x01(\x03R\vstartOffset\"G\n" + "\fstart_offset\x18\x05 \x01(\x03R\vstartOffset\"\x8b\x01\n" +
"\x1bValidateArgumentTypeRequest\x12\x14\n" + "\x1bValidateArgumentTypeRequest\x12\x14\n" +
"\x05value\x18\x01 \x01(\tR\x05value\x12\x12\n" + "\x05value\x18\x01 \x01(\tR\x05value\x12\x12\n" +
"\x04type\x18\x02 \x01(\tR\x04type\"V\n" + "\x04type\x18\x02 \x01(\tR\x04type\x12\x1d\n" +
"\n" +
"binding_id\x18\x03 \x01(\tR\tbindingId\x12#\n" +
"\rargument_name\x18\x04 \x01(\tR\fargumentName\"V\n" +
"\x1cValidateArgumentTypeResponse\x12\x14\n" + "\x1cValidateArgumentTypeResponse\x12\x14\n" +
"\x05valid\x18\x01 \x01(\bR\x05valid\x12 \n" + "\x05valid\x18\x01 \x01(\bR\x05valid\x12 \n" +
"\vdescription\x18\x02 \x01(\tR\vdescription\"K\n" + "\vdescription\x18\x02 \x01(\tR\vdescription\"K\n" +

View File

@ -587,11 +587,13 @@ func paginate(total int64, size int64, start int64) pageInfo {
This function is ONLY a helper for the UI - the arguments are validated properly This function is ONLY a helper for the UI - the arguments are validated properly
on the StartAction -> Executor chain. This is here basically to provide helpful on the StartAction -> Executor chain. This is here basically to provide helpful
error messages more quickly before starting the action. error messages more quickly before starting the action.
It uses the same validation logic as the executor, including mangling argument
values (e.g., datetime formatting, checkbox title-to-value conversion).
*/ */
func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Request[apiv1.ValidateArgumentTypeRequest]) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) { func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Request[apiv1.ValidateArgumentTypeRequest]) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
err := executor.TypeSafetyCheck("", req.Msg.Value, req.Msg.Type) err := api.validateArgumentTypeInternal(req.Msg)
desc := "" desc := ""
if err != nil { if err != nil {
desc = err.Error() desc = err.Error()
} }
@ -602,6 +604,38 @@ func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Reque
}), nil }), nil
} }
func (api *oliveTinAPI) validateArgumentTypeInternal(msg *apiv1.ValidateArgumentTypeRequest) error {
if msg.BindingId == "" || msg.ArgumentName == "" {
return executor.TypeSafetyCheck("", msg.Value, msg.Type)
}
arg, action := api.findArgumentForValidation(msg.BindingId, msg.ArgumentName)
if arg == nil {
return fmt.Errorf("argument not found")
}
return executor.ValidateArgument(arg, msg.Value, action)
}
func (api *oliveTinAPI) findArgumentForValidation(bindingId string, argumentName string) (*config.ActionArgument, *config.Action) {
binding := api.executor.FindBindingByID(bindingId)
if binding == nil || binding.Action == nil {
return nil, nil
}
arg := api.findArgumentByName(binding.Action, argumentName)
return arg, binding.Action
}
func (api *oliveTinAPI) findArgumentByName(action *config.Action, name string) *config.ActionArgument {
for i := range action.Arguments {
if action.Arguments[i].Name == name {
return &action.Arguments[i]
}
}
return nil
}
func (api *oliveTinAPI) WhoAmI(ctx ctx.Context, req *connect.Request[apiv1.WhoAmIRequest]) (*connect.Response[apiv1.WhoAmIResponse], error) { func (api *oliveTinAPI) WhoAmI(ctx ctx.Context, req *connect.Request[apiv1.WhoAmIRequest]) (*connect.Response[apiv1.WhoAmIResponse], error) {
user := auth.UserFromApiCall(ctx, req, api.cfg) user := auth.UserFromApiCall(ctx, req, api.cfg)

View File

@ -175,6 +175,25 @@ func typecheckActionArgument(arg *config.ActionArgument, value string, action *c
return typecheckActionArgumentFound(value, action, arg) return typecheckActionArgumentFound(value, action, arg)
} }
// ValidateArgument validates a single argument value using the same logic as the executor.
// It applies mangling transformations and performs full validation including null checks,
// choice validation, and type safety checks.
func ValidateArgument(arg *config.ActionArgument, value string, action *config.Action) error {
if arg == nil {
return fmt.Errorf("ValidateArgument: arg is nil")
}
if action == nil {
return fmt.Errorf("ValidateArgument: action is nil")
}
// Apply mangling transformations
mangledValue := MangleArgumentValue(arg, value, action.Title)
// Use the same validation path as the executor
return typecheckActionArgument(arg, mangledValue, action)
}
func typecheckActionArgumentFound(value string, action *config.Action, arg *config.ActionArgument) error { func typecheckActionArgumentFound(value string, action *config.Action, arg *config.ActionArgument) error {
if value == "" { if value == "" {
return typecheckNull(arg) return typecheckNull(arg)
@ -198,6 +217,8 @@ func TypeSafetyCheck(name string, value string, argumentType string) error {
return nil return nil
case "raw_string_multiline": case "raw_string_multiline":
return nil return nil
case "checkbox":
return nil
case "email": case "email":
return typeSafetyCheckEmail(value) return typeSafetyCheckEmail(value)
case "url": case "url":
@ -369,3 +390,69 @@ func mangleInvalidDatetimeValues(req *ExecutionRequest, arg *config.ActionArgume
req.Arguments[arg.Name] = timestamp.Format("2006-01-02T15:04:05") req.Arguments[arg.Name] = timestamp.Format("2006-01-02T15:04:05")
} }
} }
// MangleArgumentValue applies mangling transformations to a single argument value.
// This is used by the validation API to ensure the value matches what would be
// used during actual execution.
func MangleArgumentValue(arg *config.ActionArgument, value string, actionTitle string) string {
if arg == nil {
log.Debugf("MangleArgumentValue called with nil arg, returning value unchanged")
return value
}
if arg.Type == "datetime" {
return mangleDatetimeValue(arg, value, actionTitle)
}
if arg.Type == "checkbox" {
return mangleCheckboxValue(arg, value, actionTitle)
}
return value
}
func mangleDatetimeValue(arg *config.ActionArgument, value string, actionTitle string) string {
if arg == nil {
log.Debugf("mangleDatetimeValue called with nil arg, returning value unchanged")
return value
}
if value == "" {
return value
}
timestamp, err := time.Parse("2006-01-02T15:04", value)
if err != nil {
return value
}
log.WithFields(log.Fields{
"arg": arg.Name,
"value": value,
"actionTitle": actionTitle,
}).Warnf("Mangled invalid datetime value without seconds to :00 seconds, this issue is commonly caused by Android browsers.")
return timestamp.Format("2006-01-02T15:04:05")
}
func mangleCheckboxValue(arg *config.ActionArgument, value string, actionTitle string) string {
if arg == nil {
log.Debugf("mangleCheckboxValue called with nil arg, returning value unchanged")
return value
}
for _, choice := range arg.Choices {
if value == choice.Title {
log.WithFields(log.Fields{
"arg": arg.Name,
"oldValue": value,
"newValue": choice.Value,
"actionTitle": actionTitle,
}).Infof("Mangled checkbox value")
return choice.Value
}
}
return value
}