@@ -2,6 +2,7 @@ package servertest_test
22
33import (
44 "encoding/json"
5+ "fmt"
56 "os"
67 "os/exec"
78 "path/filepath"
@@ -40,6 +41,7 @@ func TestAPIv2(t *testing.T) {
4041
4142 t .Run ("GoClient" , func (t * testing.T ) {
4243 apiv2GoClient (t , srv , srv .URL , apiKey , owner )
44+ apiv2UsersGoClient (t , srv , srv .URL , apiKey , owner )
4345
4446 node := srv .CreateRegisteredNode (t , owner , "dut-go" )
4547 apiv2DevicesGoClient (t , srv , srv .URL , apiKey , node .ID ())
@@ -49,6 +51,7 @@ func TestAPIv2(t *testing.T) {
4951
5052 t .Run ("TSCLI" , func (t * testing.T ) {
5153 apiv2TSCLI (t , srv , srv .URL , apiKey )
54+ apiv2UsersTSCLI (t , srv .URL , apiKey , owner )
5255
5356 node := srv .CreateRegisteredNode (t , owner , "dut-tscli" )
5457 apiv2DevicesTSCLI (t , srv , srv .URL , apiKey , node .ID ())
@@ -58,6 +61,7 @@ func TestAPIv2(t *testing.T) {
5861
5962 t .Run ("Terraform" , func (t * testing.T ) {
6063 apiv2Terraform (t , srv , srv .URL , apiKey , owner )
64+ apiv2UsersTerraform (t , srv , srv .URL , apiKey , owner )
6165
6266 node := srv .CreateRegisteredNode (t , owner , "dut-tf" )
6367 apiv2DevicesACLTerraform (t , srv , srv .URL , apiKey , node .Hostname (), node .ID ())
@@ -150,6 +154,85 @@ func containsKeyID(keys []tsclient.Key, id string) bool {
150154 return false
151155}
152156
157+ func containsUserID (users []tsclient.User , id string ) bool {
158+ for _ , u := range users {
159+ if u .ID == id {
160+ return true
161+ }
162+ }
163+
164+ return false
165+ }
166+
167+ // srvUserCount is the server-side ground truth for the number of users.
168+ func srvUserCount (t * testing.T , srv * servertest.TestServer ) int {
169+ t .Helper ()
170+
171+ users , err := srv .State ().ListAllUsers ()
172+ require .NoError (t , err )
173+
174+ return len (users )
175+ }
176+
177+ // apiv2UsersGoClient exercises the Users data sources through the official SDK:
178+ // get-by-id, list, the type/role filters (member matches all, anything else
179+ // matches nothing), and a typed 404 — each cross-checked against server truth.
180+ func apiv2UsersGoClient (t * testing.T , srv * servertest.TestServer , baseURL , apiKey string , owner * types.User ) {
181+ t .Helper ()
182+
183+ ctx := t .Context ()
184+ ur := goClient (t , baseURL , apiKey ).Users ()
185+ ownerID := strconv .FormatUint (uint64 (owner .ID ), 10 )
186+
187+ got , err := ur .Get (ctx , ownerID )
188+ require .NoError (t , err )
189+ assert .Equal (t , ownerID , got .ID )
190+ assert .Equal (t , owner .Username (), got .LoginName )
191+ assert .Equal (t , tsclient .UserTypeMember , got .Type )
192+ assert .Equal (t , tsclient .UserStatusActive , got .Status )
193+ assert .Equal (t , srv .State ().ListNodesByUser (types .UserID (owner .ID )).Len (), got .DeviceCount ,
194+ "deviceCount matches the server's node count for the user" )
195+
196+ all , err := ur .List (ctx , nil , nil )
197+ require .NoError (t , err )
198+ assert .True (t , containsUserID (all , ownerID ), "owner present in user list" )
199+ assert .Len (t , all , srvUserCount (t , srv ))
200+
201+ // member matches every Headscale user; shared/admin match nothing.
202+ members , err := ur .List (ctx , tsclient .PointerTo (tsclient .UserTypeMember ), nil )
203+ require .NoError (t , err )
204+ assert .Len (t , members , len (all ))
205+
206+ shared , err := ur .List (ctx , tsclient .PointerTo (tsclient .UserTypeShared ), nil )
207+ require .NoError (t , err )
208+ assert .Empty (t , shared , "Headscale has no shared users" )
209+
210+ admins , err := ur .List (ctx , nil , tsclient .PointerTo (tsclient .UserRoleAdmin ))
211+ require .NoError (t , err )
212+ assert .Empty (t , admins , "Headscale has no admin-role users" )
213+
214+ _ , err = ur .Get (ctx , "999999" )
215+ require .Error (t , err )
216+ assert .True (t , tsclient .IsNotFound (err ), "unknown user id is a typed 404" )
217+ }
218+
219+ // apiv2UsersTSCLI exercises the user verbs through tscli, asserting the owner is
220+ // present in the list and retrievable by id.
221+ func apiv2UsersTSCLI (t * testing.T , baseURL , apiKey string , owner * types.User ) {
222+ t .Helper ()
223+
224+ run , _ := tscliRunner (t , baseURL , apiKey )
225+ ownerID := strconv .FormatUint (uint64 (owner .ID ), 10 )
226+
227+ listOut := run ("list" , "users" , "-o" , "json" )
228+ assert .Contains (t , listOut , ownerID )
229+ assert .Contains (t , listOut , `"member"` )
230+
231+ getOut := run ("get" , "user" , "--user" , ownerID , "-o" , "json" )
232+ assert .Contains (t , getOut , ownerID )
233+ assert .Contains (t , getOut , owner .Username ())
234+ }
235+
153236// srvPreAuthKey is the server-side ground truth for a key id; it fails the test
154237// if the key is absent.
155238func srvPreAuthKey (t * testing.T , srv * servertest.TestServer , id string ) types.PreAuthKey {
@@ -297,6 +380,74 @@ func apiv2Terraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey st
297380 require .NotNil (t , srvPreAuthKey (t , srv , keyID ).Revoked , "key revoked after destroy" )
298381}
299382
383+ // usersTFConfig drives the tailscale_user (by login name) and tailscale_users
384+ // data sources, plus tailscale_4via6 (provider-local compute, no server call) to
385+ // prove that data source resolves against Headscale unchanged. %s is the owner's
386+ // login name. Data sources create nothing, so the assertions are value
387+ // correctness plus no drift on re-read.
388+ const usersTFConfig = `
389+ terraform {
390+ required_providers {
391+ tailscale = {
392+ source = "tailscale/tailscale"
393+ version = "~> 0.21"
394+ }
395+ }
396+ }
397+
398+ provider "tailscale" {}
399+
400+ data "tailscale_user" "owner" {
401+ login_name = "%s"
402+ }
403+
404+ data "tailscale_users" "all" {}
405+
406+ data "tailscale_4via6" "site" {
407+ site = 7
408+ cidr = "10.1.1.0/24"
409+ }
410+
411+ output "user_id" { value = data.tailscale_user.owner.id }
412+ output "user_login_name" { value = data.tailscale_user.owner.login_name }
413+ output "user_type" { value = data.tailscale_user.owner.type }
414+ output "user_device_count" { value = data.tailscale_user.owner.device_count }
415+ output "users_count" { value = length(data.tailscale_users.all.users) }
416+ output "via6" { value = data.tailscale_4via6.site.ipv6 }
417+ `
418+
419+ // apiv2UsersTerraform runs a tofu init/apply/(no-drift)/destroy over the
420+ // tailscale_user + tailscale_users data sources (and the provider-local
421+ // tailscale_4via6), cross-checking the data-source outputs against the server's
422+ // stored users.
423+ func apiv2UsersTerraform (t * testing.T , srv * servertest.TestServer , baseURL , apiKey string , owner * types.User ) {
424+ t .Helper ()
425+
426+ tf := newTofu (t , baseURL , apiKey , fmt .Sprintf (usersTFConfig , owner .Username ()))
427+
428+ tf .run ("init" , "-no-color" , "-input=false" )
429+ tf .run ("apply" , "-auto-approve" , "-no-color" , "-input=false" , "-parallelism=1" )
430+
431+ outputs := tf .outputs ()
432+ assert .Equal (t , strconv .FormatUint (uint64 (owner .ID ), 10 ), outputs .str (t , "user_id" ))
433+ assert .Equal (t , owner .Username (), outputs .str (t , "user_login_name" ))
434+ assert .Equal (t , "member" , outputs .str (t , "user_type" ))
435+ assert .Equal (t , srv .State ().ListNodesByUser (types .UserID (owner .ID )).Len (),
436+ int (outputs .num (t , "user_device_count" )))
437+ assert .Equal (t , srvUserCount (t , srv ), int (outputs .num (t , "users_count" )))
438+
439+ // tailscale_4via6 is computed by the provider with no server call; assert it
440+ // resolved to a Tailscale 4via6 address.
441+ assert .Contains (t , outputs .str (t , "via6" ), "fd7a:115c:a1e0" , "4via6 mapped address" )
442+
443+ // A converged data-source read must produce an empty plan — drift is a read bug.
444+ tf .assertNoDrift ()
445+
446+ // destroy removes only TF state; the users persist (they are data sources).
447+ tf .run ("destroy" , "-auto-approve" , "-no-color" , "-input=false" , "-parallelism=1" )
448+ assert .GreaterOrEqual (t , srvUserCount (t , srv ), 1 , "users persist across data-source destroy" )
449+ }
450+
300451// tofu binds a tofu binary, working dir, and env for a single workspace. cmd is
301452// a closure capturing the looked-up binary so subprocess construction stays in
302453// one place.
0 commit comments