From 33a65052c9aa0bd492b937408b0cf4e618a1bdc8 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH] servertest: roundtrip the v2 API through go-client, tscli, and opentofu Exercise the full surface against the three real clients on a live server, cross-checking get-after-set, server state, and no Terraform drift. --- hscontrol/servertest/apiv2_devices_test.go | 457 +++++++++++++++++++++ hscontrol/servertest/apiv2_test.go | 427 +++++++++++++++++++ 2 files changed, 884 insertions(+) create mode 100644 hscontrol/servertest/apiv2_devices_test.go create mode 100644 hscontrol/servertest/apiv2_test.go diff --git a/hscontrol/servertest/apiv2_devices_test.go b/hscontrol/servertest/apiv2_devices_test.go new file mode 100644 index 00000000..5d85a46f --- /dev/null +++ b/hscontrol/servertest/apiv2_devices_test.go @@ -0,0 +1,457 @@ +package servertest_test + +import ( + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/juanfont/headscale/hscontrol/servertest" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + tsclient "tailscale.com/client/tailscale/v2" +) + +// baselinePolicy declares tag:ci so device SetTags is permitted. Every policy +// the device/acl tests write keeps tag:ci, so subtest order does not matter. +const baselinePolicy = `{"tagOwners":{"tag:ci":["apiv2@"]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + +// setBaselinePolicy installs baselinePolicy into both the policy manager and the +// database so device tagging and the ACL reads work. +func setBaselinePolicy(t *testing.T, srv *servertest.TestServer) { + t.Helper() + + st := srv.State() + + _, err := st.SetPolicy([]byte(baselinePolicy)) + require.NoError(t, err) + + _, err = st.SetPolicyInDB(baselinePolicy) + require.NoError(t, err) + + _, err = st.ReloadPolicy() + require.NoError(t, err) +} + +func goClient(t *testing.T, baseURL, apiKey string) *tsclient.Client { + t.Helper() + + base, err := url.Parse(baseURL) + require.NoError(t, err) + + return &tsclient.Client{BaseURL: base, APIKey: apiKey, Tailnet: "-"} +} + +// srvNodeView reads the node straight from the server's NodeStore — the +// authoritative state between client steps. Handlers mutate the NodeStore +// synchronously before responding, so a read right after a 2xx is consistent. +func srvNodeView(t *testing.T, srv *servertest.TestServer, id types.NodeID) types.NodeView { + t.Helper() + + v, ok := srv.State().GetNodeByID(id) + require.Truef(t, ok, "node %d must exist server-side", id) + require.True(t, v.Valid()) + + return v +} + +func approvedRoutesOf(nv types.NodeView) []string { + return util.PrefixesToString(nv.ApprovedRoutes().AsSlice()) +} + +func nodeListed(srv *servertest.TestServer, id types.NodeID) bool { + for _, n := range srv.State().ListNodes().All() { + if n.ID() == id { + return true + } + } + + return false +} + +// apiv2DevicesGoClient drives the device lifecycle through the official SDK, +// validating each mutation three ways: the tool's own get-after-set, the +// server-side NodeStore, and (where relevant) permutations. Tagging is done late +// because it clears user ownership; delete is last. +func apiv2DevicesGoClient(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, id types.NodeID) { + t.Helper() + + ctx := t.Context() + dr := goClient(t, baseURL, apiKey).Devices() + deviceID := strconv.FormatUint(uint64(id), 10) + + // Get — tool and server agree on identity and addresses. + dev, err := dr.Get(ctx, deviceID) + require.NoError(t, err) + assert.Equal(t, deviceID, dev.NodeID) + assert.NotEmpty(t, dev.Addresses) + assert.True(t, dev.Authorized) + assert.Equal(t, srvNodeView(t, srv, id).IPsAsString(), dev.Addresses) + assert.Equal(t, srvNodeView(t, srv, id).GivenName(), dev.Name) + + // List — present in both the tool list and the server's node list. + devs, err := dr.List(ctx) + require.NoError(t, err) + assert.True(t, containsDevice(devs, deviceID), "created device present in list") + assert.True(t, nodeListed(srv, id)) + + // SetName — get-after-set + server-side, then a second rename. + require.NoError(t, dr.SetName(ctx, deviceID, "renamed-go")) + dev, err = dr.Get(ctx, deviceID) + require.NoError(t, err) + assert.Equal(t, "renamed-go", dev.Name) + assert.Equal(t, "renamed-go", srvNodeView(t, srv, id).GivenName()) + + require.NoError(t, dr.SetName(ctx, deviceID, "renamed-go-2")) + assert.Equal(t, "renamed-go-2", srvNodeView(t, srv, id).GivenName()) + + // SetSubnetRoutes — one, two, then exit (expands to both families). + require.NoError(t, dr.SetSubnetRoutes(ctx, deviceID, []string{"10.0.0.0/24"})) + routes, err := dr.SubnetRoutes(ctx, deviceID) + require.NoError(t, err) + assert.Contains(t, routes.Enabled, "10.0.0.0/24") + assert.Contains(t, approvedRoutesOf(srvNodeView(t, srv, id)), "10.0.0.0/24") + assert.Empty(t, srvNodeView(t, srv, id).AnnouncedRoutes(), "route enabled without being announced") + + require.NoError(t, dr.SetSubnetRoutes(ctx, deviceID, []string{"10.0.0.0/24", "192.168.0.0/24"})) + approved := approvedRoutesOf(srvNodeView(t, srv, id)) + assert.Contains(t, approved, "10.0.0.0/24") + assert.Contains(t, approved, "192.168.0.0/24") + + // A single exit prefix expands to both families on the server. + require.NoError(t, dr.SetSubnetRoutes(ctx, deviceID, []string{"0.0.0.0/0"})) + approved = approvedRoutesOf(srvNodeView(t, srv, id)) + assert.Contains(t, approved, "0.0.0.0/0") + assert.Contains(t, approved, "::/0") + + // SetKey — seed a real expiry first so disabling it is a state transition. + future := time.Now().Add(24 * time.Hour) + _, _, err = srv.State().SetNodeExpiry(id, &future) + require.NoError(t, err) + require.True(t, srvNodeView(t, srv, id).Expiry().Valid()) + + require.NoError(t, dr.SetKey(ctx, deviceID, tsclient.DeviceKey{KeyExpiryDisabled: true})) + dev, err = dr.Get(ctx, deviceID) + require.NoError(t, err) + assert.True(t, dev.KeyExpiryDisabled) + assert.False(t, srvNodeView(t, srv, id).Expiry().Valid()) + + // Re-enable is a no-op; expiry stays cleared. + require.NoError(t, dr.SetKey(ctx, deviceID, tsclient.DeviceKey{KeyExpiryDisabled: false})) + assert.False(t, srvNodeView(t, srv, id).Expiry().Valid()) + + // SetTags — flips ownership to the tags; the user is dropped. + require.NoError(t, dr.SetTags(ctx, deviceID, []string{"tag:ci"})) + dev, err = dr.Get(ctx, deviceID) + require.NoError(t, err) + assert.Equal(t, []string{"tag:ci"}, dev.Tags) + assert.Equal(t, types.TaggedDevices.Username(), dev.User) + n := srvNodeView(t, srv, id) + assert.True(t, n.IsTagged()) + assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice()) + assert.False(t, n.User().Valid()) + + // Re-tagging with the same tag is idempotent. + require.NoError(t, dr.SetTags(ctx, deviceID, []string{"tag:ci"})) + assert.Equal(t, []string{"tag:ci"}, srvNodeView(t, srv, id).Tags().AsSlice()) + + // SetAuthorized(true) is a no-op success; de-auth is rejected and inert. + require.NoError(t, dr.SetAuthorized(ctx, deviceID, true)) + dev, err = dr.Get(ctx, deviceID) + require.NoError(t, err) + assert.True(t, dev.Authorized) + + require.Error(t, dr.SetAuthorized(ctx, deviceID, false), "de-authorization is unsupported") + assert.True(t, srvNodeView(t, srv, id).Valid(), "rejected de-auth left the node present") + + // Delete — gone from the tool and the server. + require.NoError(t, dr.Delete(ctx, deviceID)) + _, err = dr.Get(ctx, deviceID) + assert.Truef(t, tsclient.IsNotFound(err), "get after delete should be 404, got %v", err) + + _, ok := srv.State().GetNodeByID(id) + assert.False(t, ok, "deleted node is gone server-side") +} + +func containsDevice(devs []tsclient.Device, id string) bool { + for _, d := range devs { + if d.NodeID == id || d.ID == id { + return true + } + } + + return false +} + +// apiv2ACLGoClient round-trips the policy file: read, raw-read, conditional set. +func apiv2ACLGoClient(t *testing.T, baseURL, apiKey string) { + t.Helper() + + ctx := t.Context() + pf := goClient(t, baseURL, apiKey).PolicyFile() + + acl, err := pf.Get(ctx) + require.NoError(t, err) + assert.NotEmpty(t, acl.ETag, "GET /acl carries an ETag") + + raw, err := pf.Raw(ctx) + require.NoError(t, err) + assert.NotEmpty(t, raw.HuJSON) + + // Set with the current etag as If-Match; keep tag:ci. + require.NoError(t, pf.Set(ctx, baselinePolicy, raw.ETag)) + + updated, err := pf.Raw(ctx) + require.NoError(t, err) + assert.Contains(t, updated.HuJSON, "tag:ci") +} + +// apiv2SettingsGoClient reads the tailnet settings (write is unsupported). +func apiv2SettingsGoClient(t *testing.T, baseURL, apiKey string) { + t.Helper() + + settings, err := goClient(t, baseURL, apiKey).TailnetSettings().Get(t.Context()) + require.NoError(t, err) + assert.Equal(t, "none", string(settings.UsersRoleAllowedToJoinExternalTailnets)) +} + +// tscliRun runs tscli and fails the test on a non-zero exit. +type tscliRun func(args ...string) string + +// tscliRunner returns runners for tscli against the local server: one that +// requires success, and one that tolerates a non-zero exit (for the expected +// 404 after a delete). +func tscliRunner(t *testing.T, baseURL, apiKey string) (tscliRun, func(args ...string) error) { + t.Helper() + + bin, err := exec.LookPath("tscli") + require.NoErrorf(t, err, "tscli is required for TestAPIv2 (provided by the nix dev shell)") + + env := append( + os.Environ(), + "TSCLI_BASE_URL="+baseURL, + "TAILSCALE_API_KEY="+apiKey, + "TAILSCALE_TAILNET=-", + ) + + cmd := func(args ...string) *exec.Cmd { + c := exec.CommandContext(t.Context(), bin, args...) + c.Env = env + + return c + } + + run := func(args ...string) string { + t.Helper() + + out, err := cmd(args...).CombinedOutput() + require.NoErrorf(t, err, "tscli %s\n%s", strings.Join(args, " "), out) + + return string(out) + } + + runAllowErr := func(args ...string) error { + t.Helper() + + return cmd(args...).Run() + } + + return run, runAllowErr +} + +// apiv2DevicesTSCLI drives the device verbs through tscli, asserting each +// mutation via get-after-set (tscli's own json output) and the server NodeStore. +func apiv2DevicesTSCLI(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, id types.NodeID) { + t.Helper() + + run, runAllowErr := tscliRunner(t, baseURL, apiKey) + deviceID := strconv.FormatUint(uint64(id), 10) + + assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), deviceID) + assert.True(t, nodeListed(srv, id)) + + // Name. + run("set", "device", "name", "--device", deviceID, "--name", "renamed-tscli") + assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), "renamed-tscli") + assert.Equal(t, "renamed-tscli", srvNodeView(t, srv, id).GivenName()) + + // Routes — one, two, then exit (both families). + run("set", "device", "routes", "--device", deviceID, "--route", "10.0.0.0/24") + assert.Contains(t, run("list", "routes", "--device", deviceID, "-o", "json"), "10.0.0.0/24") + assert.Contains(t, approvedRoutesOf(srvNodeView(t, srv, id)), "10.0.0.0/24") + + run("set", "device", "routes", "--device", deviceID, "--route", "10.0.0.0/24", "--route", "192.168.0.0/24") + + approved := approvedRoutesOf(srvNodeView(t, srv, id)) + assert.Contains(t, approved, "10.0.0.0/24") + assert.Contains(t, approved, "192.168.0.0/24") + + run("set", "device", "routes", "--device", deviceID, "--route", "0.0.0.0/0") + + exitApproved := approvedRoutesOf(srvNodeView(t, srv, id)) + assert.Contains(t, exitApproved, "0.0.0.0/0") + assert.Contains(t, exitApproved, "::/0") + + // Key — seed a real expiry first so disabling it is a transition. + future := time.Now().Add(24 * time.Hour) + _, _, err := srv.State().SetNodeExpiry(id, &future) + require.NoError(t, err) + + run("set", "device", "key", "--device", deviceID, "--disable-expiry") + assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), `"keyExpiryDisabled": true`) + assert.False(t, srvNodeView(t, srv, id).Expiry().Valid()) + + // Tags — flips to tag ownership. + run("set", "device", "tags", "--device", deviceID, "--tag", "tag:ci") + assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), "tag:ci") + n := srvNodeView(t, srv, id) + assert.True(t, n.IsTagged()) + assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice()) + assert.False(t, n.User().Valid()) + + // Authorization — approve is a no-op success. + run("set", "device", "authorization", "--device", deviceID, "--approve") + assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), `"authorized": true`) + + // Delete — gone from tscli and the server. + run("delete", "device", "--device", deviceID) + require.Error(t, runAllowErr("get", "device", "--device", deviceID, "-o", "json"), "get after delete should fail") + + _, ok := srv.State().GetNodeByID(id) + assert.False(t, ok) +} + +// apiv2ACLTSCLI reads and writes the policy file through tscli. +func apiv2ACLTSCLI(t *testing.T, baseURL, apiKey string) { + t.Helper() + + run, _ := tscliRunner(t, baseURL, apiKey) + + assert.Contains(t, run("get", "policy", "--json"), "acls") + + dir := t.TempDir() + polFile := filepath.Join(dir, "policy.hujson") + require.NoError(t, os.WriteFile(polFile, []byte(baselinePolicy), 0o600)) + run("set", "policy", "--file", polFile) +} + +// apiv2SettingsTSCLI reads the tailnet settings through tscli. +func apiv2SettingsTSCLI(t *testing.T, baseURL, apiKey string) { + t.Helper() + + run, _ := tscliRunner(t, baseURL, apiKey) + assert.Contains(t, run("get", "settings", "-o", "json"), "devicesKeyDurationDays") +} + +// devicesACLTFConfig exercises Terraform device + ACL data sources AND resources. +// %s is the test node's hostname (the tailscale_device data source key). +const devicesACLTFConfig = ` +terraform { + required_providers { + tailscale = { + source = "tailscale/tailscale" + version = "~> 0.21" + } + } +} + +provider "tailscale" {} + +resource "tailscale_acl" "policy" { + acl = jsonencode({ + tagOwners = { "tag:ci" = ["apiv2@"] } + acls = [{ action = "accept", src = ["*"], dst = ["*:*"] }] + }) + overwrite_existing_content = true +} + +data "tailscale_device" "dut" { + hostname = "%s" + wait_for = "30s" +} + +data "tailscale_devices" "all" {} + +data "tailscale_acl" "current" { + depends_on = [tailscale_acl.policy] +} + +resource "tailscale_device_authorization" "dut" { + device_id = data.tailscale_device.dut.node_id + authorized = true +} + +resource "tailscale_device_tags" "dut" { + device_id = data.tailscale_device.dut.node_id + tags = ["tag:ci"] + depends_on = [tailscale_acl.policy] +} + +resource "tailscale_device_key" "dut" { + device_id = data.tailscale_device.dut.node_id + key_expiry_disabled = true +} + +resource "tailscale_device_subnet_routes" "dut" { + device_id = data.tailscale_device.dut.node_id + routes = ["10.0.0.0/24"] +} + +output "dut_node_id" { value = data.tailscale_device.dut.node_id } +output "dut_addresses" { value = data.tailscale_device.dut.addresses } +output "dut_authorized" { value = data.tailscale_device.dut.authorized } +output "device_count" { value = length(data.tailscale_devices.all.devices) } +output "acl_hujson" { value = data.tailscale_acl.current.hujson } +output "enabled_routes" { value = tailscale_device_subnet_routes.dut.routes } +` + +// apiv2DevicesACLTerraform runs a tofu init/apply/destroy over the device and +// ACL data sources and resources, asserting no post-apply drift, the data-source +// outputs (read path) against the server truth, and the resulting server state +// (write path). parallelism=1 avoids racing concurrent mutations on the one +// shared node. +func apiv2DevicesACLTerraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey, hostname string, id types.NodeID) { + t.Helper() + + tf := newTofu(t, baseURL, apiKey, fmt.Sprintf(devicesACLTFConfig, hostname)) + + tf.run("init", "-no-color", "-input=false") + tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + + // Data sources resolved to real values that match the server. + outputs := tf.outputs() + assert.Equal(t, strconv.FormatUint(uint64(id), 10), outputs.str(t, "dut_node_id")) + assert.ElementsMatch(t, srvNodeView(t, srv, id).IPsAsString(), outputs.strSlice(t, "dut_addresses")) + outputs.jsonEq(t, "dut_authorized", true) + assert.Equal(t, srv.State().ListNodes().Len(), int(outputs.num(t, "device_count"))) + assert.Contains(t, outputs.str(t, "acl_hujson"), "tag:ci") + assert.Contains(t, outputs.strSlice(t, "enabled_routes"), "10.0.0.0/24") + + // Server-side: the resources actually applied (write path). + n := srvNodeView(t, srv, id) + assert.True(t, n.IsTagged()) + assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice()) + assert.Contains(t, approvedRoutesOf(n), "10.0.0.0/24") + assert.False(t, n.Expiry().Valid()) + + pol, err := srv.State().GetPolicy() + require.NoError(t, err) + assert.Contains(t, pol.Data, "tag:ci", "tailscale_acl wrote the policy") + + // A converged config must produce an empty plan — drift is a read/write bug. + tf.assertNoDrift() + + // destroy resets the policy; the node is a data source, so it persists. + // Tags/expiry teardown are no-ops on Headscale, so they are not reverted. + tf.run("destroy", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + + _, ok := srv.State().GetNodeByID(id) + assert.True(t, ok, "data-source node persists across destroy") +} diff --git a/hscontrol/servertest/apiv2_test.go b/hscontrol/servertest/apiv2_test.go new file mode 100644 index 00000000..eadc6b3e --- /dev/null +++ b/hscontrol/servertest/apiv2_test.go @@ -0,0 +1,427 @@ +package servertest_test + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/juanfont/headscale/hscontrol/servertest" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + tsclient "tailscale.com/client/tailscale/v2" +) + +// TestAPIv2 proves the v2 API's Tailscale-compatible (ported) endpoints against the three real +// clients it exists to support: the official Go SDK, tscli, and the Tailscale +// Terraform provider (via OpenTofu). All three run against one Headscale bound +// to a real loopback port, authenticating with a user-owned API key. +// +// Every mutation is validated three ways — the tool's own get-after-set, the +// server-side NodeStore/state, and (for Terraform) a no-change plan proving no +// drift — so a server/provider read-write mismatch fails loudly. +// +// tscli and tofu are required, not optional: they ship in the nix dev shell, so +// a missing binary means a broken environment and the test fails rather than +// silently skipping. +func TestAPIv2(t *testing.T) { + srv := servertest.NewServer(t, servertest.WithRealListener()) + owner := srv.CreateUser(t, "apiv2") + apiKey := srv.CreateAPIKey(t, owner) + + // tag:ci must exist in policy for device SetTags; every policy the tests + // write keeps it, so subtest order is irrelevant. Terraform runs last so its + // ACL teardown does not strand the others. + setBaselinePolicy(t, srv) + + t.Run("GoClient", func(t *testing.T) { + apiv2GoClient(t, srv, srv.URL, apiKey, owner) + + node := srv.CreateRegisteredNode(t, owner, "dut-go") + apiv2DevicesGoClient(t, srv, srv.URL, apiKey, node.ID()) + apiv2ACLGoClient(t, srv.URL, apiKey) + apiv2SettingsGoClient(t, srv.URL, apiKey) + }) + + t.Run("TSCLI", func(t *testing.T) { + apiv2TSCLI(t, srv, srv.URL, apiKey) + + node := srv.CreateRegisteredNode(t, owner, "dut-tscli") + apiv2DevicesTSCLI(t, srv, srv.URL, apiKey, node.ID()) + apiv2ACLTSCLI(t, srv.URL, apiKey) + apiv2SettingsTSCLI(t, srv.URL, apiKey) + }) + + t.Run("Terraform", func(t *testing.T) { + apiv2Terraform(t, srv, srv.URL, apiKey, owner) + + node := srv.CreateRegisteredNode(t, owner, "dut-tf") + apiv2DevicesACLTerraform(t, srv, srv.URL, apiKey, node.Hostname(), node.ID()) + }) +} + +// apiv2GoClient exercises the official SDK with untagged (user-owned) keys — the +// default Terraform/tscli path — validating each operation against the server's +// stored PreAuthKey, plus ephemeral and default-expiry permutations. +func apiv2GoClient(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) { + t.Helper() + + ctx := t.Context() + keys := goClient(t, baseURL, apiKey).Keys() + wantOwner := strconv.FormatUint(uint64(owner.ID), 10) + + var req tsclient.CreateKeyRequest + + req.Description = "go-client" + req.ExpirySeconds = 3600 + req.Capabilities.Devices.Create.Reusable = true + + created, err := keys.CreateAuthKey(ctx, req) + require.NoError(t, err) + assert.NotEmpty(t, created.ID) + assert.NotEmpty(t, created.Key, "secret returned on create") + assert.Equal(t, "go-client", created.Description) + assert.Equal(t, wantOwner, created.UserID, "user-owned key reports its owner") + + // Server-side: the stored key matches the request and is owned by the user. + pak := srvPreAuthKey(t, srv, created.ID) + assert.True(t, pak.Reusable) + assert.False(t, pak.Ephemeral) + assert.Empty(t, pak.Tags, "no tags -> user-owned") + require.NotNil(t, pak.User) + assert.Equal(t, owner.ID, pak.User.ID) + assert.Equal(t, "go-client", pak.Description) + require.NotNil(t, pak.CreatedAt) + require.NotNil(t, pak.Expiration) + assert.InDelta(t, 3600, pak.Expiration.Sub(*pak.CreatedAt).Seconds(), 5) + + got, err := keys.Get(ctx, created.ID) + require.NoError(t, err) + assert.Equal(t, created.ID, got.ID) + assert.Empty(t, got.Key, "secret omitted on get") + assert.Equal(t, "go-client", got.Description) + assert.False(t, got.Invalid) + // The SDK decodes our integer expirySeconds into a Duration of nanoseconds, + // so never assert it numerically; the lifetime rides on Expires-Created. + assert.InDelta(t, 3600, got.Expires.Sub(got.Created).Seconds(), 5) + + list, err := keys.List(ctx, true) + require.NoError(t, err) + assert.True(t, containsKeyID(list, created.ID), "created key present in list") + + // DELETE soft-revokes (Tailscale-faithful): the key stays retrievable, now + // invalid, until the collector reaps it. + require.NoError(t, keys.Delete(ctx, created.ID)) + revoked, err := keys.Get(ctx, created.ID) + require.NoError(t, err, "revoked key stays retrievable") + assert.True(t, revoked.Invalid, "revoked key reports invalid") + require.NotNil(t, srvPreAuthKey(t, srv, created.ID).Revoked, "key soft-revoked server-side") + + // Permutation — ephemeral key. + var ephReq tsclient.CreateKeyRequest + + ephReq.Capabilities.Devices.Create.Ephemeral = true + eph, err := keys.CreateAuthKey(ctx, ephReq) + require.NoError(t, err) + assert.True(t, srvPreAuthKey(t, srv, eph.ID).Ephemeral) + require.NoError(t, keys.Delete(ctx, eph.ID)) + + // Permutation — default expiry (omit ExpirySeconds -> 90 days). + def, err := keys.CreateAuthKey(ctx, tsclient.CreateKeyRequest{}) + require.NoError(t, err) + defKey := srvPreAuthKey(t, srv, def.ID) + require.NotNil(t, defKey.CreatedAt) + require.NotNil(t, defKey.Expiration) + assert.InDelta(t, 7776000, defKey.Expiration.Sub(*defKey.CreatedAt).Seconds(), 5) + require.NoError(t, keys.Delete(ctx, def.ID)) +} + +func containsKeyID(keys []tsclient.Key, id string) bool { + for _, k := range keys { + if k.ID == id { + return true + } + } + + return false +} + +// srvPreAuthKey is the server-side ground truth for a key id; it fails the test +// if the key is absent. +func srvPreAuthKey(t *testing.T, srv *servertest.TestServer, id string) types.PreAuthKey { + t.Helper() + + pak := findPAKByID(t, srv, id) + require.NotNilf(t, pak, "pre-auth key %s not found server-side", id) + + return *pak +} + +// findPAKByID returns the stored key with the given stringified id, or nil. +func findPAKByID(t *testing.T, srv *servertest.TestServer, id string) *types.PreAuthKey { + t.Helper() + + want, err := strconv.ParseUint(id, 10, 64) + require.NoError(t, err) + + keys, err := srv.State().ListPreAuthKeys() + require.NoError(t, err) + + for i := range keys { + if keys[i].ID == want { + return &keys[i] + } + } + + return nil +} + +// apiv2TSCLI exercises tscli with a tagged key, validating server-side that the +// stored key carries the requested tags and metadata. +func apiv2TSCLI(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string) { + t.Helper() + + run, _ := tscliRunner(t, baseURL, apiKey) + + out := run( + "create", "key", + "--type", "authkey", + "--description", "tscli", + "--expiry", "1h", + "--reusable", + "--tags", "tag:ci", + "-o", "json", + ) + + var created struct { + ID string `json:"id"` + Key string `json:"key"` + } + require.NoErrorf(t, json.Unmarshal([]byte(out), &created), "tscli create output: %s", out) + assert.NotEmpty(t, created.ID) + assert.NotEmpty(t, created.Key, "secret returned on create") + + // Server-side: tagged, reusable, described, no owner. + pak := srvPreAuthKey(t, srv, created.ID) + assert.Equal(t, []string{"tag:ci"}, pak.Tags) + assert.True(t, pak.Reusable) + assert.Equal(t, "tscli", pak.Description) + assert.Nil(t, pak.User, "tagged key has no owning user") + + getOut := run("get", "key", "--key", created.ID, "-o", "json") + + var got struct { + Key string `json:"key"` + } + require.NoError(t, json.Unmarshal([]byte(getOut), &got)) + assert.Empty(t, got.Key, "secret omitted on get") + + assert.Contains(t, run("list", "keys", "--all", "-o", "json"), created.ID) + + // DELETE soft-revokes: the key stays retrievable (invalid) server-side until + // the collector reaps it. + run("delete", "key", "--key", created.ID) + assert.Contains(t, run("get", "key", "--key", created.ID, "-o", "json"), `"invalid": true`) + require.NotNil(t, srvPreAuthKey(t, srv, created.ID).Revoked, "key soft-revoked server-side") +} + +// terraformConfig drives the tailscale_tailnet_key resource against the local +// server. No tags, so the key is owned by the API key's user — the default +// Terraform path. Outputs expose the provider's read-back for value + drift +// checks. +const terraformConfig = ` +terraform { + required_providers { + tailscale = { + source = "tailscale/tailscale" + version = "~> 0.21" + } + } +} + +provider "tailscale" {} + +resource "tailscale_tailnet_key" "test" { + reusable = true + ephemeral = false + preauthorized = true + expiry = 3600 + description = "tofu-roundtrip" +} + +output "key_id" { value = tailscale_tailnet_key.test.id } +output "key_reusable" { value = tailscale_tailnet_key.test.reusable } +output "key_ephemeral" { value = tailscale_tailnet_key.test.ephemeral } +output "key_description" { value = tailscale_tailnet_key.test.description } +` + +// apiv2Terraform runs a tofu init→apply→(no-drift)→destroy roundtrip on a +// tailnet key, cross-checking the provider outputs and the server's stored key. +func apiv2Terraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) { + t.Helper() + + tf := newTofu(t, baseURL, apiKey, terraformConfig) + + tf.run("init", "-no-color", "-input=false") + tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + + outputs := tf.outputs() + keyID := outputs.str(t, "key_id") + require.NotEmpty(t, keyID) + outputs.jsonEq(t, "key_reusable", true) + outputs.jsonEq(t, "key_ephemeral", false) + outputs.jsonEq(t, "key_description", "tofu-roundtrip") + + // Server-side: the key exists, user-owned, with the requested attributes. + pak := srvPreAuthKey(t, srv, keyID) + assert.Equal(t, "tofu-roundtrip", pak.Description) + assert.True(t, pak.Reusable) + assert.False(t, pak.Ephemeral) + assert.Empty(t, pak.Tags, "no tags -> user-owned key") + require.NotNil(t, pak.User) + assert.Equal(t, owner.ID, pak.User.ID) + require.NotNil(t, pak.CreatedAt) + require.NotNil(t, pak.Expiration) + assert.InDelta(t, 3600, pak.Expiration.Sub(*pak.CreatedAt).Seconds(), 5) + + // A converged config must produce an empty plan — drift is a read/write bug. + tf.assertNoDrift() + + // destroy DELETEs the key, which soft-revokes it: the row is kept (revoked) + // until the collector reaps it. + tf.run("destroy", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + require.NotNil(t, srvPreAuthKey(t, srv, keyID).Revoked, "key revoked after destroy") +} + +// tofu binds a tofu binary, working dir, and env for a single workspace. cmd is +// a closure capturing the looked-up binary so subprocess construction stays in +// one place. +type tofu struct { + t *testing.T + cmd func(args ...string) *exec.Cmd +} + +func newTofu(t *testing.T, baseURL, apiKey, config string) *tofu { + t.Helper() + + bin, err := exec.LookPath("tofu") + require.NoErrorf(t, err, "tofu is required for TestAPIv2 (provided by the nix dev shell)") + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(config), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "plugin-cache"), 0o755)) + + env := append( + os.Environ(), + "TAILSCALE_BASE_URL="+baseURL, + "TAILSCALE_API_KEY="+apiKey, + "TAILSCALE_TAILNET=-", + // Keep provider plugins inside the temp dir so the run is self-contained. + "TF_PLUGIN_CACHE_DIR="+filepath.Join(dir, "plugin-cache"), + ) + + cmd := func(args ...string) *exec.Cmd { + c := exec.CommandContext(t.Context(), bin, args...) + c.Dir = dir + c.Env = env + + return c + } + + return &tofu{t: t, cmd: cmd} +} + +func (tf *tofu) run(args ...string) string { + tf.t.Helper() + + out, err := tf.cmd(args...).CombinedOutput() + require.NoErrorf(tf.t, err, "tofu %s\n%s", strings.Join(args, " "), out) + + return string(out) +} + +// assertNoDrift fails if a no-change plan reports changes. plan +// -detailed-exitcode returns 0 = no changes, 1 = error, 2 = drift. +func (tf *tofu) assertNoDrift() { + tf.t.Helper() + + out, err := tf.cmd("plan", "-detailed-exitcode", "-no-color", "-input=false", "-parallelism=1").CombinedOutput() + if err == nil { + return + } + + var exit *exec.ExitError + require.ErrorAsf(tf.t, err, &exit, "tofu plan\n%s", out) + require.Equalf(tf.t, 0, exit.ExitCode(), + "no-change plan after apply must be empty; drift means a provider read disagrees with desired state:\n%s", out) +} + +func (tf *tofu) outputs() tofuOutputs { + tf.t.Helper() + + out := tf.run("output", "-json", "-no-color") + + var raw map[string]struct { + Value json.RawMessage `json:"value"` + } + require.NoError(tf.t, json.Unmarshal([]byte(out), &raw)) + + o := make(tofuOutputs, len(raw)) + for k, v := range raw { + o[k] = v.Value + } + + return o +} + +// tofuOutputs is the decoded `tofu output -json`, keyed by output name. +type tofuOutputs map[string]json.RawMessage + +func (o tofuOutputs) raw(t *testing.T, key string) json.RawMessage { + t.Helper() + + v, ok := o[key] + require.Truef(t, ok, "output %q missing", key) + + return v +} + +func (o tofuOutputs) str(t *testing.T, key string) string { + t.Helper() + + var s string + require.NoError(t, json.Unmarshal(o.raw(t, key), &s)) + + return s +} + +func (o tofuOutputs) num(t *testing.T, key string) float64 { + t.Helper() + + var n float64 + require.NoError(t, json.Unmarshal(o.raw(t, key), &n)) + + return n +} + +func (o tofuOutputs) strSlice(t *testing.T, key string) []string { + t.Helper() + + var s []string + require.NoError(t, json.Unmarshal(o.raw(t, key), &s)) + + return s +} + +// jsonEq asserts the output decodes equal to want (handles bools/strings/numbers). +func (o tofuOutputs) jsonEq(t *testing.T, key string, want any) { + t.Helper() + + wantJSON, err := json.Marshal(want) + require.NoError(t, err) + require.JSONEq(t, string(wantJSON), string(o.raw(t, key))) +}