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

This commit is contained in:
jamesread 2025-12-23 16:42:00 +00:00
parent 5d061eb1a0
commit 09e1f0f984
9 changed files with 263 additions and 10 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,48 @@ 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) var err error
desc := "" desc := ""
// If binding_id and argument_name are provided, use the full validation path
if req.Msg.BindingId != "" && req.Msg.ArgumentName != "" {
binding := api.executor.FindBindingByID(req.Msg.BindingId)
if binding == nil || binding.Action == nil {
return connect.NewResponse(&apiv1.ValidateArgumentTypeResponse{
Valid: false,
Description: "action binding not found",
}), nil
}
// Find the specific argument
var arg *config.ActionArgument
for i := range binding.Action.Arguments {
if binding.Action.Arguments[i].Name == req.Msg.ArgumentName {
arg = &binding.Action.Arguments[i]
break
}
}
if arg == nil {
return connect.NewResponse(&apiv1.ValidateArgumentTypeResponse{
Valid: false,
Description: "argument not found",
}), nil
}
// Use the same validation path as the executor (includes mangling)
err = executor.ValidateArgument(arg, req.Msg.Value, binding.Action)
} else {
// Fallback to simple type check if binding_id/argument_name not provided
// (for backwards compatibility, though this path doesn't handle choices/null checks)
err = executor.TypeSafetyCheck("", req.Msg.Value, req.Msg.Type)
}
if err != nil { if err != nil {
desc = err.Error() desc = err.Error()
} }

View File

@ -175,6 +175,17 @@ 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 {
// 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 +209,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 +382,42 @@ 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.Type == "datetime" {
if value == "" {
return value
}
timestamp, err := time.Parse("2006-01-02T15:04", value)
if err == nil {
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")
}
}
if arg.Type == "checkbox" {
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
}