os-backlog: reject flag-like positional board names (card #1818501985909868001)

board-ensure/activate/archive took NAME positionally with no validation, so
`os-backlog board-ensure --board apprise-api` silently created a board
literally named "--board". Add a shared validate_board_name! guard, checked
right after each command shifts its name arg and before build_client, so a
flag-like name fails loudly with no Planka API call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuWQ8oJbFU9uPKp7dAB1Pn
This commit is contained in:
jared 2026-07-13 16:35:40 -04:00
parent 58f330fdf3
commit 8be597838d
2 changed files with 65 additions and 0 deletions

View File

@ -40,6 +40,18 @@ def fail_soft(message)
exit 1 exit 1
end end
# Guards board-ensure/activate/archive's positional NAME argument against a
# mistaken flag-style invocation (e.g. `board-ensure --board apprise-api`,
# where `--board` is swallowed as the name and `apprise-api` is dropped
# silently). Called immediately after each command shifts its name arg,
# before build_client, so a bad name never reaches Planka.
def validate_board_name!(name, command)
return unless name.start_with?("--")
fail_soft("#{command}: board name #{name.inspect} must not start with \"--\" " \
"(looks like a flag was passed positionally — check your quoting/flags)")
end
def build_client def build_client
require "planka_api" require "planka_api"
client = Planka::Client.new client = Planka::Client.new
@ -190,6 +202,7 @@ case command
when "board-ensure" when "board-ensure"
name = rest.shift name = rest.shift
fail_soft("board-ensure requires a board name") unless name fail_soft("board-ensure requires a board name") unless name
validate_board_name!(name, "board-ensure")
project = parse_project_flag(rest) project = parse_project_flag(rest)
client = build_client client = build_client
result = Backlog::BoardEnsurer.new(client: client).ensure(name, project_name: project) result = Backlog::BoardEnsurer.new(client: client).ensure(name, project_name: project)
@ -199,12 +212,14 @@ when "board-ensure"
when "activate" when "activate"
name = rest.shift name = rest.shift
fail_soft("activate requires a board name") unless name fail_soft("activate requires a board name") unless name
validate_board_name!(name, "activate")
client = build_client client = build_client
board = Backlog::BoardEnsurer.new(client: client).activate(name) board = Backlog::BoardEnsurer.new(client: client).activate(name)
puts "activated: #{board.name}" puts "activated: #{board.name}"
when "archive" when "archive"
name = rest.shift name = rest.shift
fail_soft("archive requires a board name") unless name fail_soft("archive requires a board name") unless name
validate_board_name!(name, "archive")
fail_soft("archive requires explicit --yes (human go-ahead only)") unless rest.include?("--yes") fail_soft("archive requires explicit --yes (human go-ahead only)") unless rest.include?("--yes")
client = build_client client = build_client

View File

@ -0,0 +1,50 @@
require_relative "test_helper"
# Card #1818501985909868001: a mistaken flag-style invocation like
# `os-backlog board-ensure --board apprise-api` must fail loudly with a
# usage error instead of silently creating a Planka board literally named
# "--board". These are CLI-contract tests (shell out to bin/os-backlog)
# because the bug is about what the positional NAME arg parsing accepts
# BEFORE any Planka client/network call is made — no PLANKA_* env vars are
# set, so if validation didn't happen first, these would instead fail on
# network auth with a different message.
class BoardNameValidationCliTest < Minitest::Test
BIN = File.expand_path("../bin/os-backlog", __dir__)
# Strip any live Planka credentials from the child's env: these tests
# assert that flag-like names are rejected BEFORE build_client ever runs,
# so no test here may be allowed to reach the network even accidentally.
def run_cli(*args)
env = { "PLANKA_BASE_URL" => "", "PLANKA_USERNAME" => "", "PLANKA_PASSWORD" => "" }
out = IO.popen(env, [RbConfig.ruby, BIN, *args], err: [:child, :out], &:read)
[out, $?.exitstatus]
end
def test_board_ensure_rejects_flag_like_name
out, status = run_cli("board-ensure", "--board", "apprise-api")
refute_equal 0, status
assert_match(/board name.*must not start with/i, out)
end
def test_activate_rejects_flag_like_name
out, status = run_cli("activate", "--board")
refute_equal 0, status
assert_match(/board name.*must not start with/i, out)
end
def test_archive_rejects_flag_like_name
out, status = run_cli("archive", "--board", "--yes")
refute_equal 0, status
assert_match(/board name.*must not start with/i, out)
end
def test_board_ensure_accepts_normal_name_and_fails_later_on_network
out, status = run_cli("board-ensure", "apprise-api")
refute_equal 0, status
refute_match(/board name.*must not start with/i, out)
end
end