Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Added
- `cb tailscale client` command to create, list, and destroy team Tailscale
OAuth clients.
- `cb tailscale connect` now accepts `--client` as an alternative to
`--authkey`.

## [3.7.2] - 2026-09-17
### Changed
Expand Down
144 changes: 144 additions & 0 deletions spec/cb/tailscale_client_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
require "../spec_helper"
include CB

TEAM_ID = "p5jflp7w3zhe7bu7c4s2t4e2wq"
CLIENT_ID = "pkdpq6yynjgjbps4otxd7il2u4"

def oauth_client
Model::TailscaleOAuthClient.new(
id: CLIENT_ID,
name: "production",
team_id: TEAM_ID,
)
end

Spectator.describe TailscaleClientCreate do
subject(action) { described_class.new client: client, output: IO::Memory.new }
let(client) { Client.new TEST_TOKEN }

mock_client

it "validates that required arguments are present" do
expect(&.validate).to raise_error Program::Error, /Missing required argument/
end

it "#run sends the create fields and prints the Bridge id" do
action.team_id = TEAM_ID
action.name = "production"
action.tailscale_client_id = "k123456CNTRL"
action.tailscale_client_secret = "tskey-client-example"
action.tags << "tag:production"
action.tags << "tag:other"

expect(client).to receive(:create_tailscale_oauth_client).with(
TEAM_ID,
client_id: "k123456CNTRL",
client_secret: "tskey-client-example",
name: "production",
tags: ["tag:production", "tag:other"],
).and_return(oauth_client)

action.call

printed = action.output.to_s
expect(printed).to contain CLIENT_ID
expect(printed).to contain TEAM_ID
expect(printed).to contain "production"
expect(printed).to_not contain "tskey-client-example"
end
end

Spectator.describe TailscaleClientList do
subject(action) { described_class.new client: client, output: IO::Memory.new }
let(client) { Client.new TEST_TOKEN }

mock_client

it "validates that team is present" do
expect(&.validate).to raise_error Program::Error, /Missing required argument/
end

it "#run prints a table of id, team, and name" do
action.team_id = TEAM_ID
expect(client).to receive(:get_tailscale_oauth_clients).with(TEAM_ID).and_return([oauth_client])

action.call

printed = action.output.to_s
expect(printed).to contain "ID"
expect(printed).to contain CLIENT_ID
expect(printed).to contain TEAM_ID
expect(printed).to contain "production"
end

it "#run can omit the table header" do
action.team_id = TEAM_ID
action.no_header = true
expect(client).to receive(:get_tailscale_oauth_clients).with(TEAM_ID).and_return([oauth_client])

action.call

expect(action.output.to_s).to_not contain "ID"
expect(action.output.to_s).to contain CLIENT_ID
end

it "#run can print json" do
action.team_id = TEAM_ID
action.format = "json"
expect(client).to receive(:get_tailscale_oauth_clients).with(TEAM_ID).and_return([oauth_client])

action.call

payload = JSON.parse(action.output.to_s)
expect(payload["clients"][0]["id"]).to eq CLIENT_ID
expect(payload["clients"][0]["team_id"]).to eq TEAM_ID
expect(payload["clients"][0]["name"]).to eq "production"
expect(payload["clients"][0]["client_secret"]?).to be_nil
end
end

Spectator.describe TailscaleClientDestroy do
subject(action) { described_class.new client: client, output: IO::Memory.new }
let(client) { Client.new TEST_TOKEN }

mock_client

it "validates that team and client are present" do
action.team_id = TEAM_ID
expect(&.validate).to raise_error Program::Error, /Missing required argument/
end

it "#run deletes by team and Bridge client id" do
action.team_id = TEAM_ID
action.client_id = CLIENT_ID
expect(client).to receive(:destroy_tailscale_oauth_client).with(TEAM_ID, CLIENT_ID).and_return(oauth_client)

action.call

expect(action.output.to_s).to contain CLIENT_ID
expect(action.output.to_s).to contain "production"
end
end

Spectator.describe Completion do
it "suggests tailscale client commands and create flags" do
client = Client.new TEST_TOKEN

commands = Completion.parse(client, "cb tailscale ")
expect(commands).to contain "client\tmanage tailscale oauth clients"

subcommands = Completion.parse(client, "cb tailscale client ")
expect(subcommands).to contain "create\tregister a tailscale oauth client"
expect(subcommands).to contain "list\tlist tailscale oauth clients"
expect(subcommands).to contain "destroy\tremove a tailscale oauth client"

flags = Completion.parse(client, "cb tailscale client create ")
expect(flags).to contain "--team\tchoose team"
expect(flags).to contain "--tailscale-client-id\tclient id from tailscale"
expect(flags).to contain "--tag\tacl tag"

after_id = Completion.parse(client, "cb tailscale client create --tailscale-client-id k123 ")
expect(after_id).to_not contain "--tailscale-client-id\tclient id from tailscale"
expect(after_id).to contain "--tag\tacl tag"
end
end
95 changes: 95 additions & 0 deletions spec/cb/tailscale_oauth_client_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
require "../spec_helper"

Spectator.describe CB::Model::TailscaleOAuthClient do
it "parses the fields the API returns" do
json = %({
"id": "pkdpq6yynjgjbps4otxd7il2u4",
"name": "production",
"team_id": "p5jflp7w3zhe7bu7c4s2t4e2wq",
"created_at": "2026-10-05T00:00:00Z",
"updated_at": "2026-10-05T01:00:00Z"
})

client = CB::Model::TailscaleOAuthClient.from_json(json)

expect(client.id).to eq "pkdpq6yynjgjbps4otxd7il2u4"
expect(client.name).to eq "production"
expect(client.team_id).to eq "p5jflp7w3zhe7bu7c4s2t4e2wq"
expect(client.created_at).to eq Time.utc(2026, 10, 5)
expect(client.updated_at).to eq Time.utc(2026, 10, 5, 1)
end
end

private class RecordingTailscaleClient < CB::Client
getter calls = [] of Tuple(String, String, String?)
property responses = [] of String

def exec(method, path, body : String? = nil)
calls << {method, path, body}
HTTP::Client::Response.new(200, body: responses.shift)
end
end

Spectator.describe CB::Client do
let(client) { RecordingTailscaleClient.new }
let(resource) do
%({
"id": "pkdpq6yynjgjbps4otxd7il2u4",
"name": "production",
"team_id": "p5jflp7w3zhe7bu7c4s2t4e2wq",
"created_at": "2026-10-05T00:00:00Z",
"updated_at": "2026-10-05T00:00:00Z"
})
end

it "posts a Tailscale OAuth client create body" do
client.responses << resource

created = client.create_tailscale_oauth_client(
"p5jflp7w3zhe7bu7c4s2t4e2wq",
client_id: "k123456CNTRL",
client_secret: "tskey-client-example",
name: "production",
tags: ["tag:production"],
)

expect(created.id).to eq "pkdpq6yynjgjbps4otxd7il2u4"
method, path, body = client.calls.first
expect(method).to eq "POST"
expect(path).to eq "teams/p5jflp7w3zhe7bu7c4s2t4e2wq/tailscale-oauth-clients"
payload = JSON.parse(body.not_nil!)
expect(payload["client_id"]).to eq "k123456CNTRL"
expect(payload["client_secret"]).to eq "tskey-client-example"
expect(payload["name"]).to eq "production"
expect(payload["tags"]).to eq ["tag:production"]
end

it "pages the Tailscale OAuth client list" do
client.responses << %({"clients":[#{resource}],"has_more":true,"next_cursor":"cursor-2"})
client.responses << %({"clients":[],"has_more":false})

listed = client.get_tailscale_oauth_clients("p5jflp7w3zhe7bu7c4s2t4e2wq")

expect(listed.map(&.id)).to eq ["pkdpq6yynjgjbps4otxd7il2u4"]
expect(client.calls.map(&.[1])).to eq [
"teams/p5jflp7w3zhe7bu7c4s2t4e2wq/tailscale-oauth-clients?order_field=id",
"teams/p5jflp7w3zhe7bu7c4s2t4e2wq/tailscale-oauth-clients?order_field=id&cursor=cursor-2",
]
expect(client.calls.map(&.[0])).to eq ["GET", "GET"]
end

it "deletes a Tailscale OAuth client by team and id" do
client.responses << resource

destroyed = client.destroy_tailscale_oauth_client(
"p5jflp7w3zhe7bu7c4s2t4e2wq",
"pkdpq6yynjgjbps4otxd7il2u4",
)

expect(destroyed.id).to eq "pkdpq6yynjgjbps4otxd7il2u4"
method, path, body = client.calls.first
expect(method).to eq "DELETE"
expect(path).to eq "teams/p5jflp7w3zhe7bu7c4s2t4e2wq/tailscale-oauth-clients/pkdpq6yynjgjbps4otxd7il2u4"
expect(body).to be_nil
end
end
51 changes: 51 additions & 0 deletions spec/cb/tailscale_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,57 @@ Spectator.describe TailscaleConnect do

action.call
end

it "rejects authkey and client together" do
action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4"
action.auth_key = "tskey-abcdef1432341818"
action.client_id = "n4k7q2m9p3r6s8t1u5v7w9x2y4"

expect(&.validate).to raise_error Program::Error, /not both/
end

it "requires authkey or client" do
action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4"

expect(&.validate).to raise_error Program::Error, /authkey or client/
end

it "#run sends auth_key when --authkey is set" do
action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4"
action.auth_key = "tskey-abcdef1432341818"

expect(client).to receive(:put).with(
"clusters/pkdpq6yynjgjbps4otxd7il2u4/actions/tailscale-connect",
{"auth_key" => "tskey-abcdef1432341818"},
).and_return HTTP::Client::Response.new(200, body: {message: "hi"}.to_json)

action.call
expect(action.output.to_s).to contain "hi"
end

it "#run sends tailscale_oauth_client_id when --client is set" do
action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4"
action.client_id = "n4k7q2m9p3r6s8t1u5v7w9x2y4"

expect(client).to receive(:put).with(
"clusters/pkdpq6yynjgjbps4otxd7il2u4/actions/tailscale-connect",
{"tailscale_oauth_client_id" => "n4k7q2m9p3r6s8t1u5v7w9x2y4"},
).and_return HTTP::Client::Response.new(200, body: {message: "hi"}.to_json)

action.call
end
end

Spectator.describe Completion do
it "offers connect --client unless --authkey is already set" do
client = Client.new TEST_TOKEN
flags = Completion.parse(client, "cb tailscale connect ")
expect(flags).to contain "--client\tbridge oauth client id"
expect(flags).to contain "--authkey\tpre-authentication key"

with_key = Completion.parse(client, "cb tailscale connect --authkey tskey-example ")
expect(with_key).to_not contain "--client\tbridge oauth client id"
end
end

Spectator.describe TailscaleDisconnect do
Expand Down
Loading
Loading