servertest: add real-listener option and node/key helpers

Add WithRealListener for tests needing a real loopback port, plus
CreateAPIKey and CreateRegisteredNode for the v2 roundtrip.
This commit is contained in:
Kristoffer Dalby
2026-06-20 20:16:51 +00:00
parent 8898c69d0a
commit d5555c6b8c
+62 -5
View File
@@ -5,6 +5,7 @@
package servertest
import (
"context"
"net"
"net/http"
"net/netip"
@@ -43,6 +44,7 @@ type serverConfig struct {
nodeExpiry time.Duration
batcherWorkers int
taildropEnabled bool
realListener bool
}
func defaultServerConfig() *serverConfig {
@@ -82,6 +84,14 @@ func WithNodeExpiry(d time.Duration) ServerOption {
return func(c *serverConfig) { c.nodeExpiry = d }
}
// WithRealListener binds the HTTP API to a real loopback TCP port instead of
// the in-process memnet, so external processes — the Tailscale SDK over a real
// socket, tscli, OpenTofu — can reach the API. The Noise/[TestClient] control
// path still uses memnet, so this is for REST/API tests only.
func WithRealListener() ServerOption {
return func(c *serverConfig) { c.realListener = true }
}
// WithTaildropEnabled toggles the Taildrop file-sharing feature.
// Defaults to true to match production. Pass false to verify
// behaviour when an operator has switched the toggle off — e.g.
@@ -164,13 +174,24 @@ func NewServer(tb testing.TB, opts ...ServerOption) *TestServer {
app.StartBatcherForTest(tb)
app.StartEphemeralGCForTest(tb)
// Start the HTTP server over an in-memory network so that all
// TCP connections stay in-process.
var memNetwork memnet.Network
// Start the HTTP server. By default it binds an in-memory network so all
// TCP connections stay in-process; WithRealListener swaps in a real loopback
// port so external binaries can connect.
var (
memNetwork memnet.Network
ln net.Listener
)
if sc.realListener {
var lc net.ListenConfig
ln, err = lc.Listen(context.Background(), "tcp", "127.0.0.1:0")
} else {
ln, err = memNetwork.Listen("tcp", "127.0.0.1:443")
}
ln, err := memNetwork.Listen("tcp", "127.0.0.1:443")
if err != nil {
tb.Fatalf("servertest: memnet Listen: %v", err)
tb.Fatalf("servertest: Listen: %v", err)
}
httpServer := &http.Server{
@@ -233,6 +254,42 @@ func (s *TestServer) CreateUser(tb testing.TB, name string) *types.User {
return u
}
// CreateAPIKey mints an API key string (the Bearer/Basic credential). When
// owner is non-nil the key is owned by that user, so the v2 API mints
// user-owned (untagged) auth keys on its behalf.
func (s *TestServer) CreateAPIKey(tb testing.TB, owner *types.User) string {
tb.Helper()
keyStr, key, err := s.st.CreateAPIKey(nil)
if err != nil {
tb.Fatalf("servertest: CreateAPIKey: %v", err)
}
if owner != nil {
err := s.st.SetAPIKeyUser(key.ID, types.UserID(owner.ID))
if err != nil {
tb.Fatalf("servertest: SetAPIKeyUser: %v", err)
}
}
return keyStr
}
// CreateRegisteredNode mints a registered node (allocated IPs, registered
// method) in BOTH the database and the in-memory NodeStore, then returns its
// view. The v2 device endpoints resolve nodes via State.GetNodeByID, which reads
// the NodeStore, so a DB-only node would be invisible to them.
func (s *TestServer) CreateRegisteredNode(tb testing.TB, owner *types.User, hostname ...string) types.NodeView {
tb.Helper()
node := s.st.CreateRegisteredNodeForTest(owner, hostname...)
// CreateRegisteredNodeForTest sets UserID but leaves the User association
// unloaded; the device response renders the owner login, so attach it.
node.User = owner
return s.st.PutNodeInStoreForTest(*node)
}
// CreatePreAuthKey creates a reusable pre-auth key for the given user.
func (s *TestServer) CreatePreAuthKey(tb testing.TB, userID types.UserID) string {
tb.Helper()