fix: harden checklist JSON wire format, entity pagination, and review findings across API and UI

This commit is contained in:
jamesread 2026-07-07 10:14:17 +01:00
parent f3a1315e00
commit 9dc51df01d
24 changed files with 331 additions and 83 deletions

View File

@ -152,8 +152,11 @@ function selectedChoiceIndex(choices) {
function openList() { function openList() {
document.dispatchEvent(new CustomEvent(closeOthersEvent, { detail: { id: props.id } })) document.dispatchEvent(new CustomEvent(closeOthersEvent, { detail: { id: props.id } }))
const wasClosed = !isOpen.value
isOpen.value = true isOpen.value = true
highlightedIndex.value = selectedChoiceIndex(filteredChoices.value) if (wasClosed) {
highlightedIndex.value = selectedChoiceIndex(filteredChoices.value)
}
} }
function closeList() { function closeList() {

View File

@ -72,6 +72,7 @@
const pageSize = ref(10) const pageSize = ref(10)
const tableError = ref('') const tableError = ref('')
let fetchTimer = null let fetchTimer = null
let fetchSequence = 0
const hasTable = computed(() => (props.definition.properties?.length ?? 0) > 0) const hasTable = computed(() => (props.definition.properties?.length ?? 0) > 0)
@ -120,6 +121,7 @@
return return
} }
const requestId = ++fetchSequence
tableError.value = '' tableError.value = ''
try { try {
const response = await window.client.getEntities({ const response = await window.client.getEntities({
@ -129,10 +131,18 @@
pageSize: pageSize.value pageSize: pageSize.value
}) })
if (requestId !== fetchSequence) {
return
}
const definition = response.entityDefinitions?.find(def => def.title === props.definition.title) const definition = response.entityDefinitions?.find(def => def.title === props.definition.title)
tableInstances.value = definition?.instances ?? [] tableInstances.value = definition?.instances ?? []
totalInstances.value = definition?.totalInstances ?? 0 totalInstances.value = definition?.totalInstances ?? 0
} catch (err) { } catch (err) {
if (requestId !== fetchSequence) {
return
}
console.error('Failed to fetch entity instances:', err) console.error('Failed to fetch entity instances:', err)
tableError.value = 'Failed to load entity instances.' tableError.value = 'Failed to load entity instances.'
tableInstances.value = [] tableInstances.value = []

View File

@ -1,9 +1,27 @@
function parseLegacyChecklistValue(value) {
return value.split(',').map((segment) => segment.trim()).filter((segment) => segment !== '')
}
export function parseChecklistValue(value) { export function parseChecklistValue(value) {
if (!value || value === '') { if (!value || value === '') {
return [] return []
} }
return value.split(',').map((segment) => segment.trim()).filter((segment) => segment !== '') const trimmed = value.trim()
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed)
if (!Array.isArray(parsed)) {
return []
}
return parsed.map((segment) => String(segment).trim()).filter((segment) => segment !== '')
} catch {
return []
}
}
return parseLegacyChecklistValue(value)
} }
export function formatChecklistValue(selected) { export function formatChecklistValue(selected) {
@ -11,7 +29,7 @@ export function formatChecklistValue(selected) {
return '' return ''
} }
return selected.join(',') return JSON.stringify(selected)
} }
export function toggleChoice(selected, value) { export function toggleChoice(selected, value) {

View File

@ -13,14 +13,20 @@ const choices = [
{ title: 'Photos', value: 'photos' } { title: 'Photos', value: 'photos' }
] ]
test('parseChecklistValue splits comma-delimited values', () => { test('parseChecklistValue parses JSON-encoded values', () => {
assert.deepEqual(parseChecklistValue('documents,photos'), ['documents', 'photos']) assert.deepEqual(parseChecklistValue('["documents","photos"]'), ['documents', 'photos'])
assert.deepEqual(parseChecklistValue('documents, photos'), ['documents', 'photos']) assert.deepEqual(parseChecklistValue('["kitchen,bedroom","hallway"]'), ['kitchen,bedroom', 'hallway'])
assert.deepEqual(parseChecklistValue(''), []) assert.deepEqual(parseChecklistValue(''), [])
}) })
test('formatChecklistValue joins selected values', () => { test('parseChecklistValue accepts legacy comma-delimited values', () => {
assert.equal(formatChecklistValue(['documents', 'photos']), 'documents,photos') assert.deepEqual(parseChecklistValue('documents,photos'), ['documents', 'photos'])
assert.deepEqual(parseChecklistValue('documents, photos'), ['documents', 'photos'])
})
test('formatChecklistValue joins selected values as JSON', () => {
assert.equal(formatChecklistValue(['documents', 'photos']), '["documents","photos"]')
assert.equal(formatChecklistValue(['kitchen,bedroom']), '["kitchen,bedroom"]')
assert.equal(formatChecklistValue([]), '') assert.equal(formatChecklistValue([]), '')
}) })

View File

@ -7,11 +7,15 @@ export function readPrefilledArgumentsFromNavigation() {
return {} return {}
} }
export function getInitialArgumentValue(paramName, prefilledArguments) { export function getInitialArgumentValue(paramName, prefilledArguments = {}) {
if (Object.prototype.hasOwnProperty.call(prefilledArguments, paramName)) { const safePrefilledArguments = prefilledArguments && typeof prefilledArguments === 'object'
return prefilledArguments[paramName] ? prefilledArguments
: {}
if (Object.prototype.hasOwnProperty.call(safePrefilledArguments, paramName)) {
return safePrefilledArguments[paramName]
} }
const params = new URLSearchParams(window.location.search.substring(1)) const params = new URLSearchParams(window.location.search)
return params.get(paramName) return params.get(paramName)
} }

View File

@ -19,9 +19,12 @@
<template v-if="actionArguments.length > 0"> <template v-if="actionArguments.length > 0">
<template v-for="arg in actionArguments" :key="arg.name"> <template v-for="arg in actionArguments" :key="arg.name">
<label :for="arg.type === 'checklist' ? undefined : arg.name"> <label v-if="arg.type !== 'checklist'" :for="arg.name">
{{ formatLabel(arg.title) }} {{ formatLabel(arg.title) }}
</label> </label>
<div v-else class="argument-label">
{{ formatLabel(arg.title) }}
</div>
<datalist v-if="(arg.suggestions && Object.keys(arg.suggestions).length > 0) || getBrowserSuggestions(arg).length > 0" :id="`${arg.name}-choices`"> <datalist v-if="(arg.suggestions && Object.keys(arg.suggestions).length > 0) || getBrowserSuggestions(arg).length > 0" :id="`${arg.name}-choices`">
<option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key"> <option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key">
@ -383,35 +386,31 @@ function formatArgumentValueForApi(arg, rawValue) {
return rawValue ?? '' return rawValue ?? ''
} }
function getArgumentValues() { function getSelectedArgumentEntries() {
const ret = [] const entries = []
for (const arg of actionArguments.value) { for (const arg of actionArguments.value) {
if (!shouldSendArgument(arg)) { if (!shouldSendArgument(arg)) {
continue continue
} }
ret.push({ entries.push({
name: arg.name, name: arg.name,
value: formatArgumentValueForApi(arg, argValues.value[arg.name]) value: formatArgumentValueForApi(arg, argValues.value[arg.name])
}) })
} }
return ret return entries
}
function getArgumentValues() {
return getSelectedArgumentEntries().map(({ name, value }) => ({ name, value }))
} }
function getArgumentMapForTemplate() { function getArgumentMapForTemplate() {
const args = {} return Object.fromEntries(
getSelectedArgumentEntries().map(({ name, value }) => [name, value])
for (const arg of actionArguments.value) { )
if (!shouldSendArgument(arg)) {
continue
}
args[arg.name] = formatArgumentValueForApi(arg, argValues.value[arg.name])
}
return args
} }
function updateJustificationFromTemplate() { function updateJustificationFromTemplate() {

View File

@ -63,6 +63,7 @@
<section v-if="entityDetails && relatedActions.length > 0" class="transparent"> <section v-if="entityDetails && relatedActions.length > 0" class="transparent">
<div class="dashboard-row"> <div class="dashboard-row">
<fieldset> <fieldset>
<legend class="visually-hidden">Related actions</legend>
<template v-for="(related, idx) in relatedActions" :key="related.action?.bindingId || idx"> <template v-for="(related, idx) in relatedActions" :key="related.action?.bindingId || idx">
<ActionButton <ActionButton
v-if="related.action" v-if="related.action"

View File

@ -62,7 +62,17 @@ async function waitForTerminalOutput(expectedValue, label = 'Selected segments')
async function waitForTerminalOutputPattern(pattern) { async function waitForTerminalOutputPattern(pattern) {
await pollTerminal( await pollTerminal(
(output) => pattern.test(output), (output) => pattern.test(output),
10000 DEFAULT_UI_WAIT_MS
)
}
async function waitForChecklistValue(expectedValue) {
await webdriver.wait(
new Condition('wait for checklist hidden value', async () => {
const valueInput = await webdriver.findElement(By.css('.choice-checklist > input'))
return (await valueInput.getAttribute('value')) === expectedValue
}),
DEFAULT_UI_WAIT_MS
) )
} }
@ -108,7 +118,7 @@ describe('config: checklist', function () {
const selectNone = await webdriver.findElement(By.xpath("//button[normalize-space()='Select none']")) const selectNone = await webdriver.findElement(By.xpath("//button[normalize-space()='Select none']"))
await selectNone.click() await selectNone.click()
await webdriver.sleep(300) await waitForChecklistValue('')
const valueInput = await webdriver.findElement(By.css('.choice-checklist > input')) const valueInput = await webdriver.findElement(By.css('.choice-checklist > input'))
expect(await valueInput.getAttribute('value')).to.equal('') expect(await valueInput.getAttribute('value')).to.equal('')
@ -131,7 +141,7 @@ describe('config: checklist', function () {
await submitChecklistForm() await submitChecklistForm()
await waitForLogsPage() await waitForLogsPage()
await waitForExecutionComplete() await waitForExecutionComplete()
await waitForTerminalOutput('kitchen,bedroom,hallway') await waitForTerminalOutput('["kitchen","bedroom","hallway"]')
}) })
it('Checklist toggles individual choices before submit', async function () { it('Checklist toggles individual choices before submit', async function () {
@ -143,7 +153,7 @@ describe('config: checklist', function () {
await submitChecklistForm() await submitChecklistForm()
await waitForLogsPage() await waitForLogsPage()
await waitForExecutionComplete() await waitForExecutionComplete()
await waitForTerminalOutput('kitchen,bedroom,hallway') await waitForTerminalOutput('["kitchen","bedroom","hallway"]')
}) })
it('Checklist entity argument renders choices from entities', async function () { it('Checklist entity argument renders choices from entities', async function () {
@ -163,6 +173,6 @@ describe('config: checklist', function () {
await submitChecklistForm() await submitChecklistForm()
await waitForLogsPage() await waitForLogsPage()
await waitForExecutionComplete() await waitForExecutionComplete()
await waitForTerminalOutput('attic', 'Selected rooms') await waitForTerminalOutput('["attic"]', 'Selected rooms')
}) })
}) })

View File

@ -9,7 +9,10 @@ import (
"github.com/OliveTin/OliveTin/internal/entities" "github.com/OliveTin/OliveTin/internal/entities"
) )
const defaultEntityInstancesPageSize = 10 const (
defaultEntityInstancesPageSize = 10
maxEntityInstancesPageSize = 100
)
func (api *oliveTinAPI) buildEntityDefinitionsResponse(req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition { func (api *oliveTinAPI) buildEntityDefinitionsResponse(req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
if req != nil && req.EntityType != "" { if req != nil && req.EntityType != "" {
@ -91,6 +94,9 @@ func normalizeEntityInstancesPageSize(pageSize int32) int32 {
if pageSize < 1 { if pageSize < 1 {
return defaultEntityInstancesPageSize return defaultEntityInstancesPageSize
} }
if pageSize > maxEntityInstancesPageSize {
return maxEntityInstancesPageSize
}
return pageSize return pageSize
} }
@ -125,17 +131,18 @@ func entityInstanceMatchesFilter(instance *apiv1.Entity, filter string) bool {
} }
func paginateEntityInstances(instances []*apiv1.Entity, page, pageSize int32) []*apiv1.Entity { func paginateEntityInstances(instances []*apiv1.Entity, page, pageSize int32) []*apiv1.Entity {
start := int((page - 1) * pageSize) count := int64(len(instances))
if start >= len(instances) { start := int64(page-1) * int64(pageSize)
if start >= count {
return []*apiv1.Entity{} return []*apiv1.Entity{}
} }
end := start + int(pageSize) end := start + int64(pageSize)
if end > len(instances) { if end > count {
end = len(instances) end = count
} }
return instances[start:end] return instances[int(start):int(end)]
} }
func entityFieldsForResponse(data any, properties []config.EntityProperty) map[string]string { func entityFieldsForResponse(data any, properties []config.EntityProperty) map[string]string {

View File

@ -64,6 +64,61 @@ func TestGetEntitiesPaginatesAndFiltersInstances(t *testing.T) {
assert.Equal(t, "1", pagedResp.Msg.EntityDefinitions[0].Instances[0].UniqueKey) assert.Equal(t, "1", pagedResp.Msg.EntityDefinitions[0].Instances[0].UniqueKey)
} }
func TestGetEntitiesUnfilteredIncludesConfiguredProperties(t *testing.T) {
entities.ClearEntitiesOfType("server")
entities.AddEntity("server", "0", map[string]any{"name": "alpha", "hostname": "alpha.example.com", "ip": "10.0.0.1"})
t.Cleanup(func() {
entities.ClearEntitiesOfType("server")
})
cfg := config.DefaultConfig()
cfg.Entities = []*config.EntityFile{
{
Name: "server",
Properties: []config.EntityProperty{
{Name: "hostname", Title: "Hostname"},
},
},
}
cfg.Sanitize()
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
resp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{}))
require.NoError(t, err)
serverDef := findEntityDefinition(resp.Msg.EntityDefinitions, "server")
require.NotNil(t, serverDef)
require.Len(t, serverDef.Properties, 1)
assert.Equal(t, "hostname", serverDef.Properties[0].Name)
assert.Equal(t, int32(1), serverDef.TotalInstances)
assert.Empty(t, serverDef.Instances)
}
func TestPaginateEntityInstancesHandlesLargePageValues(t *testing.T) {
instances := []*apiv1.Entity{
{UniqueKey: "0"},
{UniqueKey: "1"},
}
assert.Empty(t, paginateEntityInstances(instances, 1<<30, 1))
assert.Empty(t, paginateEntityInstances(instances, 2, 1<<30))
assert.Equal(t, []*apiv1.Entity{{UniqueKey: "1"}}, paginateEntityInstances(instances, 2, 1))
}
func findEntityDefinition(definitions []*apiv1.EntityDefinition, title string) *apiv1.EntityDefinition {
for _, definition := range definitions {
if definition.Title == title {
return definition
}
}
return nil
}
func TestGetEntityRestrictsFieldsToConfiguredProperties(t *testing.T) { func TestGetEntityRestrictsFieldsToConfiguredProperties(t *testing.T) {
entities.ClearEntitiesOfType("server") entities.ClearEntitiesOfType("server")
entities.AddEntity("server", "0", map[string]any{ entities.AddEntity("server", "0", map[string]any{

View File

@ -17,13 +17,13 @@ type relatedActionCandidate struct {
} }
func (api *oliveTinAPI) relatedActionsForEntity(user *authpublic.AuthenticatedUser, entityType string, entity *entities.Entity) []*apiv1.EntityRelatedAction { func (api *oliveTinAPI) relatedActionsForEntity(user *authpublic.AuthenticatedUser, entityType string, entity *entities.Entity) []*apiv1.EntityRelatedAction {
rr := api.createDashboardRenderRequest(user, entityType, entity.UniqueKey) renderRequest := api.createDashboardRenderRequest(user, entityType, entity.UniqueKey)
populateActiveBindingStates(rr) populateActiveBindingStates(renderRequest)
candidates := collectRelatedActionCandidates(api, user, entityType, entity) candidates := collectRelatedActionCandidates(api, user, entityType, entity)
sortRelatedActionCandidates(candidates) sortRelatedActionCandidates(candidates)
return buildEntityRelatedActions(candidates, rr) return buildEntityRelatedActions(candidates, renderRequest)
} }
func collectRelatedActionCandidates(api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType string, entity *entities.Entity) []relatedActionCandidate { func collectRelatedActionCandidates(api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType string, entity *entities.Entity) []relatedActionCandidate {
@ -102,8 +102,12 @@ func buildPrefilledArgumentsForEntity(action *config.Action, entityType string,
} }
func sortRelatedActionCandidates(candidates []relatedActionCandidate) { func sortRelatedActionCandidates(candidates []relatedActionCandidate) {
sort.Slice(candidates, func(i, j int) bool { sort.SliceStable(candidates, func(i, j int) bool {
return candidates[i].binding.ConfigOrder < candidates[j].binding.ConfigOrder if candidates[i].binding.ConfigOrder != candidates[j].binding.ConfigOrder {
return candidates[i].binding.ConfigOrder < candidates[j].binding.ConfigOrder
}
return candidates[i].binding.ID < candidates[j].binding.ID
}) })
} }

View File

@ -59,17 +59,32 @@ func resolveJustificationFromTemplate(action *config.Action, binding *executor.A
resolved, err := tpl.ParseTemplateWithActionContext(templateText, bindingEntity(binding), args) resolved, err := tpl.ParseTemplateWithActionContext(templateText, bindingEntity(binding), args)
if err != nil { if err != nil {
log.WithFields(log.Fields{ log.WithFields(justificationTemplateErrorFields(templateText, binding, err)).Warn("Failed to resolve justification template")
"template": templateText,
"entity": bindingEntity(binding),
"error": err,
}).Warn("Failed to resolve justification template")
return fallback return fallback
} }
return resolved return resolved
} }
func justificationTemplateErrorFields(templateText string, binding *executor.ActionBinding, err error) log.Fields {
fields := log.Fields{
"template": templateText,
"error": err,
}
entity := bindingEntity(binding)
if entity == nil {
return fields
}
fields["entityKey"] = entity.UniqueKey
if binding.Action != nil && binding.Action.Entity != "" {
fields["entityType"] = binding.Action.Entity
}
return fields
}
func bindingEntity(binding *executor.ActionBinding) *entities.Entity { func bindingEntity(binding *executor.ActionBinding) *entities.Entity {
if binding == nil { if binding == nil {
return nil return nil

View File

@ -3,7 +3,6 @@ package api
import ( import (
"context" "context"
"testing" "testing"
"time"
"connectrpc.com/connect" "connectrpc.com/connect"
"github.com/google/uuid" "github.com/google/uuid"
@ -49,11 +48,7 @@ func TestStartActionRequiresJustificationForGuest(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, resp.Msg.ExecutionTrackingId) require.NotEmpty(t, resp.Msg.ExecutionTrackingId)
time.Sleep(200 * time.Millisecond) waitForLogJustification(t, ex, resp.Msg.ExecutionTrackingId, "New user registration foo@example.com")
entry, ok := ex.GetLog(resp.Msg.ExecutionTrackingId)
require.True(t, ok)
assert.Equal(t, "New user registration foo@example.com", entry.Justification)
} }
func TestBuildActionExposesJustificationTemplate(t *testing.T) { func TestBuildActionExposesJustificationTemplate(t *testing.T) {
@ -159,11 +154,7 @@ func TestStartActionResolvesJustificationTemplateForGuest(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, resp.Msg.ExecutionTrackingId) require.NotEmpty(t, resp.Msg.ExecutionTrackingId)
time.Sleep(200 * time.Millisecond) waitForLogJustification(t, ex, resp.Msg.ExecutionTrackingId, "stuffbox")
entry, ok := ex.GetLog(resp.Msg.ExecutionTrackingId)
require.True(t, ok)
assert.Equal(t, "stuffbox", entry.Justification)
} }
func TestValidateJustificationRequiredAllowsSystemUser(t *testing.T) { func TestValidateJustificationRequiredAllowsSystemUser(t *testing.T) {

View File

@ -286,7 +286,7 @@ func TestRestartActionRequiresJustificationWhenMissingFromStoredLog(t *testing.T
Title: "Dangerous action", Title: "Dangerous action",
Shell: "echo ok", Shell: "echo ok",
MaxConcurrent: 1, MaxConcurrent: 1,
Justification: " ", Justification: config.JustificationRequiredNoTemplate,
}, },
} }
@ -319,7 +319,7 @@ func TestRestartActionReusesStoredJustificationViaStartActionPath(t *testing.T)
Title: "Dangerous action", Title: "Dangerous action",
Shell: "echo ok", Shell: "echo ok",
MaxConcurrent: 1, MaxConcurrent: 1,
Justification: " ", Justification: config.JustificationRequiredNoTemplate,
}, },
} }

View File

@ -157,6 +157,7 @@ func validateEntityListProperties(t *testing.T, client apiv1connect.OliveTinApiS
PageSize: 10, PageSize: 10,
})) }))
require.NoError(t, err) require.NoError(t, err)
require.Len(t, resp.Msg.EntityDefinitions, 1)
serverDef := resp.Msg.EntityDefinitions[0] serverDef := resp.Msg.EntityDefinitions[0]
require.NotNil(t, serverDef, "server entity definition should be present") require.NotNil(t, serverDef, "server entity definition should be present")
@ -949,6 +950,9 @@ func TestBuildActionIncludesGroups(t *testing.T) {
func TestBuildChoicesExpandsChecklistEntityChoices(t *testing.T) { func TestBuildChoicesExpandsChecklistEntityChoices(t *testing.T) {
entities.AddEntity("room", "0", map[string]any{"hostname": "attic"}) entities.AddEntity("room", "0", map[string]any{"hostname": "attic"})
entities.AddEntity("room", "1", map[string]any{"hostname": "basement"}) entities.AddEntity("room", "1", map[string]any{"hostname": "basement"})
t.Cleanup(func() {
entities.ClearEntitiesOfType("room")
})
arg := config.ActionArgument{ arg := config.ActionArgument{
Type: "checklist", Type: "checklist",

View File

@ -0,0 +1,60 @@
package config
import (
"encoding/json"
"fmt"
"strings"
)
// ParseChecklistValue parses a checklist argument wire value.
// New values are JSON arrays; legacy comma-separated values are still accepted.
func ParseChecklistValue(value string) ([]string, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil, nil
}
if strings.HasPrefix(trimmed, "[") {
return parseJSONChecklistValue(trimmed)
}
return parseLegacyChecklistValue(value)
}
func parseJSONChecklistValue(value string) ([]string, error) {
var values []string
if err := json.Unmarshal([]byte(value), &values); err != nil {
return nil, fmt.Errorf("invalid checklist JSON value: %w", err)
}
return values, nil
}
func parseLegacyChecklistValue(value string) ([]string, error) {
segments := strings.Split(value, ",")
values := make([]string, 0, len(segments))
for _, segment := range segments {
trimmedSegment := strings.TrimSpace(segment)
if trimmedSegment == "" {
return nil, fmt.Errorf("checklist value contains an empty segment")
}
values = append(values, trimmedSegment)
}
return values, nil
}
// FormatChecklistValue serializes selected checklist values for API transport.
func FormatChecklistValue(values []string) string {
if len(values) == 0 {
return ""
}
encoded, err := json.Marshal(values)
if err != nil {
return ""
}
return string(encoded)
}

View File

@ -0,0 +1,43 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseChecklistValueJSON(t *testing.T) {
t.Parallel()
values, err := ParseChecklistValue(`["documents","photos"]`)
require.NoError(t, err)
assert.Equal(t, []string{"documents", "photos"}, values)
values, err = ParseChecklistValue(`["kitchen,bedroom","hallway"]`)
require.NoError(t, err)
assert.Equal(t, []string{"kitchen,bedroom", "hallway"}, values)
}
func TestParseChecklistValueLegacyCommaSeparated(t *testing.T) {
t.Parallel()
values, err := ParseChecklistValue("documents, photos")
require.NoError(t, err)
assert.Equal(t, []string{"documents", "photos"}, values)
}
func TestParseChecklistValueRejectsEmptyLegacySegment(t *testing.T) {
t.Parallel()
_, err := ParseChecklistValue("documents,,photos")
require.Error(t, err)
}
func TestFormatChecklistValueJSON(t *testing.T) {
t.Parallel()
assert.Equal(t, `["documents","photos"]`, FormatChecklistValue([]string{"documents", "photos"}))
assert.Equal(t, `["kitchen,bedroom"]`, FormatChecklistValue([]string{"kitchen,bedroom"}))
assert.Empty(t, FormatChecklistValue(nil))
}

View File

@ -50,6 +50,10 @@ func (action *Action) JustificationTemplateText() string {
return "" return ""
} }
if action.Justification == JustificationRequiredNoTemplate {
return ""
}
return action.Justification return action.Justification
} }

View File

@ -95,12 +95,11 @@ func validateChecklistChoicesForArgument(actionTitle string, arg ActionArgument)
} }
for _, choice := range arg.Choices { for _, choice := range arg.Choices {
if strings.Contains(choice.Value, ",") { if strings.TrimSpace(choice.Value) == "" {
return fmt.Errorf( return fmt.Errorf(
`action %q argument %q choice value %q must not contain commas`, `action %q argument %q choice value must not be empty`,
actionTitle, actionTitle,
arg.Name, arg.Name,
choice.Value,
) )
} }
} }

View File

@ -272,7 +272,7 @@ func TestValidateUniqueLocalUserAPIKeys(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
} }
func TestValidateChecklistChoiceValuesRejectsCommas(t *testing.T) { func TestValidateChecklistChoiceValuesAllowsCommas(t *testing.T) {
t.Parallel() t.Parallel()
c := DefaultConfig() c := DefaultConfig()
@ -291,6 +291,5 @@ func TestValidateChecklistChoiceValuesRejectsCommas(t *testing.T) {
}) })
err := c.validateChecklistChoiceValues() err := c.validateChecklistChoiceValues()
require.Error(t, err) require.NoError(t, err)
assert.Contains(t, err.Error(), `choice value "kitchen,bedroom" must not contain commas`)
} }

View File

@ -241,8 +241,17 @@ func typecheckChecklist(value string, arg *config.ActionArgument) error {
return fmt.Errorf("checklist argument %q requires choices", arg.Name) return fmt.Errorf("checklist argument %q requires choices", arg.Name)
} }
for _, segment := range strings.Split(value, ",") { segments, err := config.ParseChecklistValue(value)
if err := typecheckChecklistSegment(strings.TrimSpace(segment), arg); err != nil { if err != nil {
return err
}
return typecheckChecklistSegments(segments, arg)
}
func typecheckChecklistSegments(segments []string, arg *config.ActionArgument) error {
for _, segment := range segments {
if err := typecheckChecklistSegment(segment, arg); err != nil {
return err return err
} }
} }
@ -485,14 +494,21 @@ func mangleChecklistValue(arg *config.ActionArgument, value string, actionTitle
return value return value
} }
segments := strings.Split(value, ",") segments, err := config.ParseChecklistValue(value)
mangled := make([]string, len(segments)) if err != nil {
return value
}
return mangleChecklistSegments(arg, segments, actionTitle)
}
func mangleChecklistSegments(arg *config.ActionArgument, segments []string, actionTitle string) string {
mangled := make([]string, len(segments))
for i, segment := range segments { for i, segment := range segments {
mangled[i] = mangleChecklistSegment(arg, segment, actionTitle) mangled[i] = mangleChecklistSegment(arg, segment, actionTitle)
} }
return strings.Join(mangled, ",") return config.FormatChecklistValue(mangled)
} }
func mangleChecklistSegment(arg *config.ActionArgument, segment string, actionTitle string) string { func mangleChecklistSegment(arg *config.ActionArgument, segment string, actionTitle string) string {

View File

@ -196,10 +196,10 @@ func TestMangleArgumentValueChecklist(t *testing.T) {
arg := checklistTestArg() arg := checklistTestArg()
out := MangleArgumentValue(&arg, "Documents,Music", "Test action") out := MangleArgumentValue(&arg, "Documents,Music", "Test action")
assert.Equal(t, "documents,music", out) assert.Equal(t, `["documents","music"]`, out)
out = MangleArgumentValue(&arg, "documents,photos", "Test action") out = MangleArgumentValue(&arg, "documents,photos", "Test action")
assert.Equal(t, "documents,photos", out) assert.Equal(t, `["documents","photos"]`, out)
} }
func checklistEntityTestArg() config.ActionArgument { func checklistEntityTestArg() config.ActionArgument {
@ -248,7 +248,7 @@ func TestMangleArgumentValueChecklistEntityTitles(t *testing.T) {
} }
out := MangleArgumentValue(&arg, "attic room,basement room", "Test checklist entity titles") out := MangleArgumentValue(&arg, "attic room,basement room", "Test checklist entity titles")
assert.Equal(t, "attic,basement", out) assert.Equal(t, `["attic","basement"]`, out)
} }
func TestParseActionArgumentsChecklistEmptySelection(t *testing.T) { func TestParseActionArgumentsChecklistEmptySelection(t *testing.T) {

View File

@ -11,7 +11,7 @@ import (
func TestResolveJustificationUsesProvidedValue(t *testing.T) { func TestResolveJustificationUsesProvidedValue(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
action := &config.Action{Title: "Send email", Justification: " ", Shell: "echo hi"} action := &config.Action{Title: "Send email", Justification: config.JustificationRequiredNoTemplate, Shell: "echo hi"}
cfg.Actions = append(cfg.Actions, action) cfg.Actions = append(cfg.Actions, action)
ex := DefaultExecutor(cfg) ex := DefaultExecutor(cfg)
ex.RebuildActionMap() ex.RebuildActionMap()
@ -29,7 +29,7 @@ func TestResolveJustificationUsesProvidedValue(t *testing.T) {
func TestResolveJustificationCronDefault(t *testing.T) { func TestResolveJustificationCronDefault(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
action := &config.Action{Title: "Nightly backup", Justification: " ", Shell: "echo hi"} action := &config.Action{Title: "Nightly backup", Justification: config.JustificationRequiredNoTemplate, Shell: "echo hi"}
cfg.Actions = append(cfg.Actions, action) cfg.Actions = append(cfg.Actions, action)
ex := DefaultExecutor(cfg) ex := DefaultExecutor(cfg)
ex.RebuildActionMap() ex.RebuildActionMap()
@ -45,7 +45,7 @@ func TestResolveJustificationCronDefault(t *testing.T) {
func TestResolveJustificationStartupDefault(t *testing.T) { func TestResolveJustificationStartupDefault(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
action := &config.Action{Title: "Init", Justification: " ", Shell: "echo hi"} action := &config.Action{Title: "Init", Justification: config.JustificationRequiredNoTemplate, Shell: "echo hi"}
cfg.Actions = append(cfg.Actions, action) cfg.Actions = append(cfg.Actions, action)
ex := DefaultExecutor(cfg) ex := DefaultExecutor(cfg)
ex.RebuildActionMap() ex.RebuildActionMap()
@ -61,7 +61,7 @@ func TestResolveJustificationStartupDefault(t *testing.T) {
func TestResolveJustificationWebhookDefault(t *testing.T) { func TestResolveJustificationWebhookDefault(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
action := &config.Action{Title: "Deploy", Justification: " ", Exec: []string{"echo", "deploy"}} action := &config.Action{Title: "Deploy", Justification: config.JustificationRequiredNoTemplate, Exec: []string{"echo", "deploy"}}
cfg.Actions = append(cfg.Actions, action) cfg.Actions = append(cfg.Actions, action)
ex := DefaultExecutor(cfg) ex := DefaultExecutor(cfg)
ex.RebuildActionMap() ex.RebuildActionMap()
@ -95,7 +95,7 @@ func TestJustificationNotPassedToShellArgs(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
action := &config.Action{ action := &config.Action{
Title: "Echo", Title: "Echo",
Justification: " ", Justification: config.JustificationRequiredNoTemplate,
Shell: "echo {{ message }}", Shell: "echo {{ message }}",
Arguments: []config.ActionArgument{ Arguments: []config.ActionArgument{
{Name: "message", Type: "ascii_sentence"}, {Name: "message", Type: "ascii_sentence"},

View File

@ -99,7 +99,7 @@ func TestStorableArgumentsFromRequestStoresMangledChecklistValue(t *testing.T) {
args := storableArgumentsFromRequest(req) args := storableArgumentsFromRequest(req)
require.Len(t, args, 1) require.Len(t, args, 1)
assert.Equal(t, "documents,photos", args["directories"]) assert.Equal(t, `["documents","photos"]`, args["directories"])
} }
func TestCopyStorableArgumentsToLogEntry(t *testing.T) { func TestCopyStorableArgumentsToLogEntry(t *testing.T) {