2023-08-08 23:58:45 +00:00
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package web
import (
2023-10-05 18:48:45 +00:00
"encoding/json"
"errors"
2023-08-28 20:44:48 +00:00
"fmt"
"io"
"net/http"
"net/http/httptest"
2023-08-08 23:58:45 +00:00
"net/url"
2023-08-28 20:44:48 +00:00
"strings"
2023-08-08 23:58:45 +00:00
"testing"
2023-10-05 18:48:45 +00:00
"time"
2023-08-28 20:44:48 +00:00
2023-10-05 18:48:45 +00:00
"github.com/google/go-cmp/cmp"
2023-08-28 20:44:48 +00:00
"tailscale.com/client/tailscale"
2023-10-05 18:48:45 +00:00
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn/ipnstate"
2023-08-28 20:44:48 +00:00
"tailscale.com/net/memnet"
2023-10-05 18:48:45 +00:00
"tailscale.com/tailcfg"
"tailscale.com/types/views"
2023-10-18 15:48:20 +00:00
"tailscale.com/util/httpm"
2023-08-08 23:58:45 +00:00
)
func TestQnapAuthnURL ( t * testing . T ) {
query := url . Values {
"qtoken" : [ ] string { "token" } ,
}
tests := [ ] struct {
name string
in string
want string
} {
{
name : "localhost http" ,
in : "http://localhost:8088/" ,
want : "http://localhost:8088/cgi-bin/authLogin.cgi?qtoken=token" ,
} ,
{
name : "localhost https" ,
in : "https://localhost:5000/" ,
want : "https://localhost:5000/cgi-bin/authLogin.cgi?qtoken=token" ,
} ,
{
name : "IP http" ,
in : "http://10.1.20.4:80/" ,
want : "http://10.1.20.4:80/cgi-bin/authLogin.cgi?qtoken=token" ,
} ,
{
name : "IP6 https" ,
in : "https://[ff7d:0:1:2::1]/" ,
want : "https://[ff7d:0:1:2::1]/cgi-bin/authLogin.cgi?qtoken=token" ,
} ,
{
name : "hostname https" ,
in : "https://qnap.example.com/" ,
want : "https://qnap.example.com/cgi-bin/authLogin.cgi?qtoken=token" ,
} ,
{
name : "invalid URL" ,
in : "This is not a URL, it is a really really really really really really really really really really really really long string to exercise the URL truncation code in the error path." ,
want : "http://localhost/cgi-bin/authLogin.cgi?qtoken=token" ,
} ,
{
name : "err != nil" ,
in : "http://192.168.0.%31/" ,
want : "http://localhost/cgi-bin/authLogin.cgi?qtoken=token" ,
} ,
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
u := qnapAuthnURL ( tt . in , query )
if u != tt . want {
t . Errorf ( "expected url: %q, got: %q" , tt . want , u )
}
} )
}
}
2023-08-28 20:44:48 +00:00
// TestServeAPI tests the web client api's handling of
// 1. invalid endpoint errors
// 2. localapi proxy allowlist
func TestServeAPI ( t * testing . T ) {
lal := memnet . Listen ( "local-tailscaled.sock:80" )
defer lal . Close ( )
// Serve dummy localapi. Just returns "success".
2023-08-30 01:50:04 +00:00
localapi := & http . Server { Handler : http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
fmt . Fprintf ( w , "success" )
} ) }
defer localapi . Close ( )
go localapi . Serve ( lal )
2023-08-28 20:44:48 +00:00
s := & Server { lc : & tailscale . LocalClient { Dial : lal . Dial } }
tests := [ ] struct {
name string
reqPath string
wantResp string
wantStatus int
} { {
name : "invalid_endpoint" ,
reqPath : "/not-an-endpoint" ,
wantResp : "invalid endpoint" ,
wantStatus : http . StatusNotFound ,
} , {
name : "not_in_localapi_allowlist" ,
reqPath : "/local/v0/not-allowlisted" ,
wantResp : "/v0/not-allowlisted not allowed from localapi proxy" ,
wantStatus : http . StatusForbidden ,
} , {
name : "in_localapi_allowlist" ,
reqPath : "/local/v0/logout" ,
wantResp : "success" , // Successfully allowed to hit localapi.
wantStatus : http . StatusOK ,
} }
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
r := httptest . NewRequest ( "POST" , "/api" + tt . reqPath , nil )
w := httptest . NewRecorder ( )
s . serveAPI ( w , r )
res := w . Result ( )
defer res . Body . Close ( )
if gotStatus := res . StatusCode ; tt . wantStatus != gotStatus {
2023-10-18 15:48:20 +00:00
t . Errorf ( "wrong status; want=%v, got=%v" , tt . wantStatus , gotStatus )
2023-08-28 20:44:48 +00:00
}
body , err := io . ReadAll ( res . Body )
if err != nil {
t . Fatal ( err )
}
gotResp := strings . TrimSuffix ( string ( body ) , "\n" ) // trim trailing newline
if tt . wantResp != gotResp {
t . Errorf ( "wrong response; want=%q, got=%q" , tt . wantResp , gotResp )
}
} )
}
}
2023-10-05 18:48:45 +00:00
func TestGetTailscaleBrowserSession ( t * testing . T ) {
userA := & tailcfg . UserProfile { ID : tailcfg . UserID ( 1 ) }
userB := & tailcfg . UserProfile { ID : tailcfg . UserID ( 2 ) }
userANodeIP := "100.100.100.101"
userBNodeIP := "100.100.100.102"
taggedNodeIP := "100.100.100.103"
var selfNode * ipnstate . PeerStatus
tags := views . SliceOf ( [ ] string { "tag:server" } )
tailnetNodes := map [ string ] * apitype . WhoIsResponse {
userANodeIP : {
2023-10-24 19:15:10 +00:00
Node : & tailcfg . Node { ID : 1 , StableID : "1" } ,
2023-10-05 18:48:45 +00:00
UserProfile : userA ,
} ,
userBNodeIP : {
2023-10-24 19:15:10 +00:00
Node : & tailcfg . Node { ID : 2 , StableID : "2" } ,
2023-10-05 18:48:45 +00:00
UserProfile : userB ,
} ,
taggedNodeIP : {
2023-10-24 19:15:10 +00:00
Node : & tailcfg . Node { ID : 3 , StableID : "3" , Tags : tags . AsSlice ( ) } ,
2023-10-05 18:48:45 +00:00
} ,
}
lal := memnet . Listen ( "local-tailscaled.sock:80" )
defer lal . Close ( )
2023-10-18 15:48:20 +00:00
localapi := mockLocalAPI ( t , tailnetNodes , func ( ) * ipnstate . PeerStatus { return selfNode } )
2023-10-05 18:48:45 +00:00
defer localapi . Close ( )
go localapi . Serve ( lal )
2023-10-23 16:36:21 +00:00
s := & Server {
timeNow : time . Now ,
lc : & tailscale . LocalClient { Dial : lal . Dial } ,
}
2023-10-05 18:48:45 +00:00
// Add some browser sessions to cache state.
userASession := & browserSession {
ID : "cookie1" ,
2023-10-18 20:45:25 +00:00
SrcNode : 1 ,
2023-10-05 18:48:45 +00:00
SrcUser : userA . ID ,
2023-10-18 20:45:25 +00:00
Created : time . Now ( ) ,
Authenticated : false , // not yet authenticated
2023-10-05 18:48:45 +00:00
}
userBSession := & browserSession {
ID : "cookie2" ,
2023-10-18 20:45:25 +00:00
SrcNode : 2 ,
2023-10-05 18:48:45 +00:00
SrcUser : userB . ID ,
2023-10-18 20:45:25 +00:00
Created : time . Now ( ) . Add ( - 2 * sessionCookieExpiry ) ,
Authenticated : true , // expired
2023-10-05 18:48:45 +00:00
}
userASessionAuthorized := & browserSession {
ID : "cookie3" ,
2023-10-18 20:45:25 +00:00
SrcNode : 1 ,
2023-10-05 18:48:45 +00:00
SrcUser : userA . ID ,
2023-10-18 20:45:25 +00:00
Created : time . Now ( ) ,
Authenticated : true , // authenticated and not expired
2023-10-05 18:48:45 +00:00
}
s . browserSessions . Store ( userASession . ID , userASession )
s . browserSessions . Store ( userBSession . ID , userBSession )
s . browserSessions . Store ( userASessionAuthorized . ID , userASessionAuthorized )
tests := [ ] struct {
name string
selfNode * ipnstate . PeerStatus
remoteAddr string
cookie string
wantSession * browserSession
wantError error
wantIsAuthorized bool // response from session.isAuthorized
} {
{
name : "not-connected-over-tailscale" ,
selfNode : & ipnstate . PeerStatus { ID : "self" , UserID : userA . ID } ,
remoteAddr : "77.77.77.77" ,
wantSession : nil ,
wantError : errNotUsingTailscale ,
} ,
{
name : "no-session-user-self-node" ,
selfNode : & ipnstate . PeerStatus { ID : "self" , UserID : userA . ID } ,
remoteAddr : userANodeIP ,
cookie : "not-a-cookie" ,
wantSession : nil ,
wantError : errNoSession ,
} ,
{
name : "no-session-tagged-self-node" ,
selfNode : & ipnstate . PeerStatus { ID : "self" , Tags : & tags } ,
remoteAddr : userANodeIP ,
wantSession : nil ,
wantError : errNoSession ,
} ,
{
name : "not-owner" ,
selfNode : & ipnstate . PeerStatus { ID : "self" , UserID : userA . ID } ,
remoteAddr : userBNodeIP ,
wantSession : nil ,
wantError : errNotOwner ,
} ,
{
2023-10-24 19:15:10 +00:00
name : "tagged-remote-source" ,
2023-10-05 18:48:45 +00:00
selfNode : & ipnstate . PeerStatus { ID : "self" , UserID : userA . ID } ,
remoteAddr : taggedNodeIP ,
wantSession : nil ,
2023-10-24 19:15:10 +00:00
wantError : errTaggedRemoteSource ,
} ,
{
name : "tagged-local-source" ,
selfNode : & ipnstate . PeerStatus { ID : "3" } ,
remoteAddr : taggedNodeIP , // same node as selfNode
wantSession : nil ,
wantError : errTaggedLocalSource ,
} ,
{
name : "not-tagged-local-source" ,
selfNode : & ipnstate . PeerStatus { ID : "1" , UserID : userA . ID } ,
remoteAddr : userANodeIP , // same node as selfNode
cookie : userASession . ID ,
wantSession : userASession ,
wantError : nil , // should not error
2023-10-05 18:48:45 +00:00
} ,
{
name : "has-session" ,
selfNode : & ipnstate . PeerStatus { ID : "self" , UserID : userA . ID } ,
remoteAddr : userANodeIP ,
cookie : userASession . ID ,
wantSession : userASession ,
wantError : nil ,
} ,
{
name : "has-authorized-session" ,
selfNode : & ipnstate . PeerStatus { ID : "self" , UserID : userA . ID } ,
remoteAddr : userANodeIP ,
cookie : userASessionAuthorized . ID ,
wantSession : userASessionAuthorized ,
wantError : nil ,
wantIsAuthorized : true ,
} ,
{
name : "session-associated-with-different-source" ,
selfNode : & ipnstate . PeerStatus { ID : "self" , UserID : userB . ID } ,
remoteAddr : userBNodeIP ,
cookie : userASession . ID ,
wantSession : nil ,
wantError : errNoSession ,
} ,
{
name : "session-expired" ,
selfNode : & ipnstate . PeerStatus { ID : "self" , UserID : userB . ID } ,
remoteAddr : userBNodeIP ,
cookie : userBSession . ID ,
wantSession : nil ,
wantError : errNoSession ,
} ,
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
selfNode = tt . selfNode
r := & http . Request { RemoteAddr : tt . remoteAddr , Header : http . Header { } }
if tt . cookie != "" {
r . AddCookie ( & http . Cookie { Name : sessionCookieName , Value : tt . cookie } )
}
2023-11-01 20:09:59 +00:00
session , _ , err := s . getSession ( r )
2023-10-05 18:48:45 +00:00
if ! errors . Is ( err , tt . wantError ) {
t . Errorf ( "wrong error; want=%v, got=%v" , tt . wantError , err )
}
if diff := cmp . Diff ( session , tt . wantSession ) ; diff != "" {
t . Errorf ( "wrong session; (-got+want):%v" , diff )
}
2023-10-23 16:36:21 +00:00
if gotIsAuthorized := session . isAuthorized ( s . timeNow ( ) ) ; gotIsAuthorized != tt . wantIsAuthorized {
2023-10-05 18:48:45 +00:00
t . Errorf ( "wrong isAuthorized; want=%v, got=%v" , tt . wantIsAuthorized , gotIsAuthorized )
}
} )
}
}
2023-10-18 15:48:20 +00:00
// TestAuthorizeRequest tests the s.authorizeRequest function.
// 2023-10-18: These tests currently cover tailscale auth mode (not platform auth).
func TestAuthorizeRequest ( t * testing . T ) {
// Create self and remoteNode owned by same user.
// See TestGetTailscaleBrowserSession for tests of
// browser sessions w/ different users.
user := & tailcfg . UserProfile { ID : tailcfg . UserID ( 1 ) }
self := & ipnstate . PeerStatus { ID : "self" , UserID : user . ID }
remoteNode := & apitype . WhoIsResponse { Node : & tailcfg . Node { StableID : "node" } , UserProfile : user }
remoteIP := "100.100.100.101"
lal := memnet . Listen ( "local-tailscaled.sock:80" )
defer lal . Close ( )
localapi := mockLocalAPI ( t ,
map [ string ] * apitype . WhoIsResponse { remoteIP : remoteNode } ,
func ( ) * ipnstate . PeerStatus { return self } ,
)
defer localapi . Close ( )
go localapi . Serve ( lal )
s := & Server {
2023-11-02 19:13:22 +00:00
mode : ManageServerMode ,
lc : & tailscale . LocalClient { Dial : lal . Dial } ,
timeNow : time . Now ,
2023-10-18 15:48:20 +00:00
}
validCookie := "ts-cookie"
s . browserSessions . Store ( validCookie , & browserSession {
ID : validCookie ,
2023-10-18 20:45:25 +00:00
SrcNode : remoteNode . Node . ID ,
2023-10-18 15:48:20 +00:00
SrcUser : user . ID ,
2023-10-18 20:45:25 +00:00
Created : time . Now ( ) ,
Authenticated : true ,
2023-10-18 15:48:20 +00:00
} )
tests := [ ] struct {
reqPath string
reqMethod string
wantOkNotOverTailscale bool // simulates req over public internet
wantOkWithoutSession bool // simulates req over TS without valid browser session
wantOkWithSession bool // simulates req over TS with valid browser session
} { {
reqPath : "/api/data" ,
reqMethod : httpm . GET ,
wantOkNotOverTailscale : false ,
wantOkWithoutSession : true ,
wantOkWithSession : true ,
} , {
reqPath : "/api/data" ,
reqMethod : httpm . POST ,
wantOkNotOverTailscale : false ,
wantOkWithoutSession : false ,
wantOkWithSession : true ,
} , {
reqPath : "/api/somethingelse" ,
reqMethod : httpm . GET ,
wantOkNotOverTailscale : false ,
wantOkWithoutSession : false ,
wantOkWithSession : true ,
} , {
reqPath : "/assets/styles.css" ,
wantOkNotOverTailscale : false ,
wantOkWithoutSession : true ,
wantOkWithSession : true ,
} }
for _ , tt := range tests {
t . Run ( fmt . Sprintf ( "%s-%s" , tt . reqMethod , tt . reqPath ) , func ( t * testing . T ) {
doAuthorize := func ( remoteAddr string , cookie string ) bool {
r := httptest . NewRequest ( tt . reqMethod , tt . reqPath , nil )
r . RemoteAddr = remoteAddr
if cookie != "" {
r . AddCookie ( & http . Cookie { Name : sessionCookieName , Value : cookie } )
}
w := httptest . NewRecorder ( )
return s . authorizeRequest ( w , r )
}
// Do request from non-Tailscale IP.
if gotOk := doAuthorize ( "123.456.789.999" , "" ) ; gotOk != tt . wantOkNotOverTailscale {
t . Errorf ( "wantOkNotOverTailscale; want=%v, got=%v" , tt . wantOkNotOverTailscale , gotOk )
}
// Do request from Tailscale IP w/o associated session.
if gotOk := doAuthorize ( remoteIP , "" ) ; gotOk != tt . wantOkWithoutSession {
t . Errorf ( "wantOkWithoutSession; want=%v, got=%v" , tt . wantOkWithoutSession , gotOk )
}
// Do request from Tailscale IP w/ associated session.
if gotOk := doAuthorize ( remoteIP , validCookie ) ; gotOk != tt . wantOkWithSession {
t . Errorf ( "wantOkWithSession; want=%v, got=%v" , tt . wantOkWithSession , gotOk )
}
} )
}
}
2023-10-18 20:45:25 +00:00
func TestServeTailscaleAuth ( t * testing . T ) {
user := & tailcfg . UserProfile { ID : tailcfg . UserID ( 1 ) }
self := & ipnstate . PeerStatus { ID : "self" , UserID : user . ID }
remoteNode := & apitype . WhoIsResponse { Node : & tailcfg . Node { ID : 1 } , UserProfile : user }
remoteIP := "100.100.100.101"
lal := memnet . Listen ( "local-tailscaled.sock:80" )
defer lal . Close ( )
localapi := mockLocalAPI ( t ,
map [ string ] * apitype . WhoIsResponse { remoteIP : remoteNode } ,
func ( ) * ipnstate . PeerStatus { return self } ,
)
defer localapi . Close ( )
go localapi . Serve ( lal )
2023-10-19 20:13:40 +00:00
timeNow := time . Now ( )
oneHourAgo := timeNow . Add ( - time . Hour )
sixtyDaysAgo := timeNow . Add ( - sessionCookieExpiry * 2 )
2023-10-18 20:45:25 +00:00
s := & Server {
2023-11-02 19:13:22 +00:00
mode : ManageServerMode ,
lc : & tailscale . LocalClient { Dial : lal . Dial } ,
timeNow : func ( ) time . Time { return timeNow } ,
2023-10-18 20:45:25 +00:00
}
successCookie := "ts-cookie-success"
s . browserSessions . Store ( successCookie , & browserSession {
ID : successCookie ,
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
2023-10-19 20:13:40 +00:00
Created : oneHourAgo ,
AuthID : testAuthPathSuccess ,
2023-10-18 20:45:25 +00:00
AuthURL : testControlURL + testAuthPathSuccess ,
} )
failureCookie := "ts-cookie-failure"
s . browserSessions . Store ( failureCookie , & browserSession {
ID : failureCookie ,
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
2023-10-19 20:13:40 +00:00
Created : oneHourAgo ,
AuthID : testAuthPathError ,
2023-10-18 20:45:25 +00:00
AuthURL : testControlURL + testAuthPathError ,
} )
expiredCookie := "ts-cookie-expired"
s . browserSessions . Store ( expiredCookie , & browserSession {
ID : expiredCookie ,
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
2023-10-19 20:13:40 +00:00
Created : sixtyDaysAgo ,
AuthID : "/a/old-auth-url" ,
2023-10-18 20:45:25 +00:00
AuthURL : testControlURL + "/a/old-auth-url" ,
} )
tests := [ ] struct {
name string
cookie string
query string
wantStatus int
wantResp * authResponse
2023-10-19 20:13:40 +00:00
wantNewCookie bool // new cookie generated
wantSession * browserSession // session associated w/ cookie at end of request
2023-10-18 20:45:25 +00:00
} {
{
name : "new-session-created" ,
wantStatus : http . StatusOK ,
wantResp : & authResponse { OK : false , AuthURL : testControlURL + testAuthPath } ,
wantNewCookie : true ,
2023-10-19 20:13:40 +00:00
wantSession : & browserSession {
ID : "GENERATED_ID" , // gets swapped for newly created ID by test
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
Created : timeNow ,
AuthID : testAuthPath ,
AuthURL : testControlURL + testAuthPath ,
Authenticated : false ,
} ,
} ,
{
2023-10-18 20:45:25 +00:00
name : "query-existing-incomplete-session" ,
cookie : successCookie ,
wantStatus : http . StatusOK ,
wantResp : & authResponse { OK : false , AuthURL : testControlURL + testAuthPathSuccess } ,
2023-10-19 20:13:40 +00:00
wantSession : & browserSession {
ID : successCookie ,
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
Created : oneHourAgo ,
AuthID : testAuthPathSuccess ,
AuthURL : testControlURL + testAuthPathSuccess ,
Authenticated : false ,
} ,
} ,
{
2023-10-18 20:45:25 +00:00
name : "transition-to-successful-session" ,
cookie : successCookie ,
// query "wait" indicates the FE wants to make
// local api call to wait until session completed.
query : "wait=true" ,
wantStatus : http . StatusOK ,
wantResp : & authResponse { OK : true } ,
2023-10-19 20:13:40 +00:00
wantSession : & browserSession {
ID : successCookie ,
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
Created : oneHourAgo ,
AuthID : testAuthPathSuccess ,
AuthURL : testControlURL + testAuthPathSuccess ,
Authenticated : true ,
} ,
} ,
{
2023-10-18 20:45:25 +00:00
name : "query-existing-complete-session" ,
cookie : successCookie ,
wantStatus : http . StatusOK ,
wantResp : & authResponse { OK : true } ,
2023-10-19 20:13:40 +00:00
wantSession : & browserSession {
ID : successCookie ,
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
Created : oneHourAgo ,
AuthID : testAuthPathSuccess ,
AuthURL : testControlURL + testAuthPathSuccess ,
Authenticated : true ,
} ,
} ,
{
name : "transition-to-failed-session" ,
cookie : failureCookie ,
query : "wait=true" ,
wantStatus : http . StatusUnauthorized ,
wantResp : nil ,
wantSession : nil , // session deleted
} ,
{
2023-10-18 20:45:25 +00:00
name : "failed-session-cleaned-up" ,
cookie : failureCookie ,
wantStatus : http . StatusOK ,
wantResp : & authResponse { OK : false , AuthURL : testControlURL + testAuthPath } ,
wantNewCookie : true ,
2023-10-19 20:13:40 +00:00
wantSession : & browserSession {
ID : "GENERATED_ID" ,
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
Created : timeNow ,
AuthID : testAuthPath ,
AuthURL : testControlURL + testAuthPath ,
Authenticated : false ,
} ,
} ,
{
2023-10-18 20:45:25 +00:00
name : "expired-cookie-gets-new-session" ,
cookie : expiredCookie ,
wantStatus : http . StatusOK ,
wantResp : & authResponse { OK : false , AuthURL : testControlURL + testAuthPath } ,
wantNewCookie : true ,
2023-10-19 20:13:40 +00:00
wantSession : & browserSession {
ID : "GENERATED_ID" ,
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
Created : timeNow ,
AuthID : testAuthPath ,
AuthURL : testControlURL + testAuthPath ,
Authenticated : false ,
} ,
2023-10-18 20:45:25 +00:00
} ,
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
r := httptest . NewRequest ( "GET" , "/api/auth" , nil )
r . URL . RawQuery = tt . query
r . RemoteAddr = remoteIP
r . AddCookie ( & http . Cookie { Name : sessionCookieName , Value : tt . cookie } )
w := httptest . NewRecorder ( )
2023-11-02 18:28:07 +00:00
s . serveAPIAuth ( w , r )
2023-10-18 20:45:25 +00:00
res := w . Result ( )
defer res . Body . Close ( )
2023-10-19 20:13:40 +00:00
// Validate response status/data.
2023-10-18 20:45:25 +00:00
if gotStatus := res . StatusCode ; tt . wantStatus != gotStatus {
t . Errorf ( "wrong status; want=%v, got=%v" , tt . wantStatus , gotStatus )
}
var gotResp * authResponse
if res . StatusCode == http . StatusOK {
body , err := io . ReadAll ( res . Body )
if err != nil {
t . Fatal ( err )
}
if err := json . Unmarshal ( body , & gotResp ) ; err != nil {
t . Fatal ( err )
}
}
2023-10-19 20:13:40 +00:00
if diff := cmp . Diff ( gotResp , tt . wantResp ) ; diff != "" {
t . Errorf ( "wrong response; (-got+want):%v" , diff )
2023-10-18 20:45:25 +00:00
}
2023-10-19 20:13:40 +00:00
// Validate cookie creation.
sessionID := tt . cookie
2023-10-18 20:45:25 +00:00
var gotCookie bool
for _ , c := range w . Result ( ) . Cookies ( ) {
if c . Name == sessionCookieName {
gotCookie = true
2023-10-19 20:13:40 +00:00
sessionID = c . Value
2023-10-18 20:45:25 +00:00
break
}
}
if gotCookie != tt . wantNewCookie {
t . Errorf ( "wantNewCookie wrong; want=%v, got=%v" , tt . wantNewCookie , gotCookie )
}
2023-10-19 20:13:40 +00:00
// Validate browser session contents.
var gotSesson * browserSession
if s , ok := s . browserSessions . Load ( sessionID ) ; ok {
gotSesson = s . ( * browserSession )
}
if tt . wantSession != nil && tt . wantSession . ID == "GENERATED_ID" {
// If requested, swap in the generated session ID before
// comparing got/want.
tt . wantSession . ID = sessionID
}
if diff := cmp . Diff ( gotSesson , tt . wantSession ) ; diff != "" {
t . Errorf ( "wrong session; (-got+want):%v" , diff )
}
2023-10-18 20:45:25 +00:00
} )
}
}
var (
testControlURL = "http://localhost:8080"
testAuthPath = "/a/12345"
testAuthPathSuccess = "/a/will-succeed"
testAuthPathError = "/a/will-error"
)
2023-10-18 15:48:20 +00:00
// mockLocalAPI constructs a test localapi handler that can be used
// to simulate localapi responses without a functioning tailnet.
//
// self accepts a function that resolves to a self node status,
// so that tests may swap out the /localapi/v0/status response
// as desired.
func mockLocalAPI ( t * testing . T , whoIs map [ string ] * apitype . WhoIsResponse , self func ( ) * ipnstate . PeerStatus ) * http . Server {
return & http . Server { Handler : http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
switch r . URL . Path {
case "/localapi/v0/whois" :
addr := r . URL . Query ( ) . Get ( "addr" )
if addr == "" {
t . Fatalf ( "/whois call missing \"addr\" query" )
}
if node := whoIs [ addr ] ; node != nil {
if err := json . NewEncoder ( w ) . Encode ( & node ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
return
}
http . Error ( w , "not a node" , http . StatusUnauthorized )
return
case "/localapi/v0/status" :
status := ipnstate . Status { Self : self ( ) }
if err := json . NewEncoder ( w ) . Encode ( status ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
return
2023-10-18 20:45:25 +00:00
case "/localapi/v0/debug-web-client" : // used by TestServeTailscaleAuth
type reqData struct {
ID string
Src tailcfg . NodeID
}
var data reqData
if err := json . NewDecoder ( r . Body ) . Decode ( & data ) ; err != nil {
http . Error ( w , "invalid JSON body" , http . StatusBadRequest )
return
}
if data . Src == 0 {
http . Error ( w , "missing Src node" , http . StatusBadRequest )
return
}
var resp * tailcfg . WebClientAuthResponse
if data . ID == "" {
2023-10-19 20:13:40 +00:00
resp = & tailcfg . WebClientAuthResponse { ID : testAuthPath , URL : testControlURL + testAuthPath }
2023-10-18 20:45:25 +00:00
} else if data . ID == testAuthPathSuccess {
resp = & tailcfg . WebClientAuthResponse { Complete : true }
} else if data . ID == testAuthPathError {
http . Error ( w , "authenticated as wrong user" , http . StatusUnauthorized )
return
}
if err := json . NewEncoder ( w ) . Encode ( resp ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
return
2023-10-18 15:48:20 +00:00
default :
t . Fatalf ( "unhandled localapi test endpoint %q, add to localapi handler func in test" , r . URL . Path )
}
} ) }
}