arblant
firefox-vpn-client / core / internal / service / loginlink_test.go
loginlink_test.go185 lines · 5.8 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185
package service

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"
)

// A sign-in link from the app signs a browser in once, without the
// password, within its lifetime; after that it shows the expired page.
func TestLoginLink(t *testing.T) {
	d := newTestDaemon(t)
	d.localToken = "link-token"
	cfg := d.config()
	cfg.Dashboard.Username = "admin"
	password := "a long password"
	if _, err := d.updateSettings(cfg, &password); err != nil {
		t.Fatal(err)
	}
	srv := newDashboardServer(d)
	handler := srv.handler()
	get := func(path string, cookie *http.Cookie) *httptest.ResponseRecorder {
		req := httptest.NewRequest(http.MethodGet, path, nil)
		req.Host = "127.0.0.1:10820"
		if cookie != nil {
			req.AddCookie(cookie)
		}
		rec := httptest.NewRecorder()
		handler.ServeHTTP(rec, req)
		return rec
	}
	makeLink := func(token string) *httptest.ResponseRecorder {
		req := httptest.NewRequest(http.MethodPost, "/api/session/link", strings.NewReader("{}"))
		req.Host = "127.0.0.1:10820"
		req.Header.Set("Content-Type", "application/json")
		if token != "" {
			req.Header.Set(localTokenHeader, token)
		}
		rec := httptest.NewRecorder()
		handler.ServeHTTP(rec, req)
		return rec
	}

	if rec := makeLink(""); rec.Code == http.StatusOK {
		t.Fatal("a link must need the local token")
	}
	if rec := makeLink("wrong"); rec.Code == http.StatusOK {
		t.Fatal("a link must need the right local token")
	}
	rec := makeLink("link-token")
	var link struct {
		Path      string `json:"path"`
		ExpiresIn int    `json:"expires_in"`
	}
	if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &link) != nil || !strings.HasPrefix(link.Path, "/app/login/") {
		t.Fatalf("link: %d %s", rec.Code, rec.Body.String())
	}
	if link.ExpiresIn != 15*60 {
		t.Fatalf("a link lasts 15 minutes, got %d s", link.ExpiresIn)
	}

	// The password changes; the link still works.
	cfg = d.config()
	changed := "another long password"
	if _, err := d.updateSettings(cfg, &changed); err != nil {
		t.Fatal(err)
	}
	rec = get(link.Path, nil)
	if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/app/" {
		t.Fatalf("the link goes on to the dashboard: %d %q", rec.Code, rec.Header().Get("Location"))
	}
	var session *http.Cookie
	for _, c := range rec.Result().Cookies() {
		if c.Name == sessionCookieName {
			session = c
		}
	}
	if session == nil {
		t.Fatal("the link gives no session cookie")
	}
	if rec := get("/api/status", session); rec.Code != http.StatusOK {
		t.Fatalf("the session from the link does not open the API: %d", rec.Code)
	}

	// Once only.
	if rec := get(link.Path, nil); rec.Code != http.StatusGone || !strings.Contains(rec.Body.String(), "expired") {
		t.Fatalf("a used link shows the expired page: %d", rec.Code)
	}
	// Not after its lifetime.
	rec = makeLink("link-token")
	_ = json.Unmarshal(rec.Body.Bytes(), &link)
	code := strings.TrimPrefix(link.Path, "/app/login/")
	srv.mu.Lock()
	srv.links[code] = time.Now().Add(-time.Second)
	srv.mu.Unlock()
	if rec := get(link.Path, nil); rec.Code != http.StatusGone {
		t.Fatalf("an expired link shows the expired page: %d", rec.Code)
	}
	if rec := get("/app/login/unknown", nil); rec.Code != http.StatusGone {
		t.Fatalf("an unknown link shows the expired page: %d", rec.Code)
	}

	// Only so many wait at a time.
	for i := 0; i < maxLoginLinks+5; i++ {
		makeLink("link-token")
	}
	srv.mu.Lock()
	waiting := len(srv.links)
	srv.mu.Unlock()
	if waiting > maxLoginLinks {
		t.Fatalf("%d links wait, at most %d may", waiting, maxLoginLinks)
	}
}

// On the phone (RequireLocalToken) the API answers only the app's token or
// a browser the app signed in, also without a dashboard login.
func TestTokenOnlyAPI(t *testing.T) {
	d := newTestDaemon(t)
	d.localToken = "phone-token"
	d.RequireLocalToken()
	srv := newDashboardServer(d)
	handler := srv.handler()
	call := func(method, path, token string, cookie *http.Cookie) *httptest.ResponseRecorder {
		var body *strings.Reader
		if method != http.MethodGet {
			body = strings.NewReader("{}")
		} else {
			body = strings.NewReader("")
		}
		req := httptest.NewRequest(method, path, body)
		req.Host = "127.0.0.1:10820"
		if method != http.MethodGet {
			req.Header.Set("Content-Type", "application/json")
		}
		if token != "" {
			req.Header.Set(localTokenHeader, token)
		}
		if cookie != nil {
			req.AddCookie(cookie)
		}
		rec := httptest.NewRecorder()
		handler.ServeHTTP(rec, req)
		return rec
	}

	if rec := call(http.MethodGet, "/api/accounts", "", nil); rec.Code != http.StatusUnauthorized {
		t.Fatalf("another app reached the API without the token: %d", rec.Code)
	}
	if rec := call(http.MethodGet, "/api/accounts", "phone-token", nil); rec.Code != http.StatusOK {
		t.Fatalf("the app's token must open the API: %d", rec.Code)
	}
	if rec := call(http.MethodPost, "/api/session", "", nil); rec.Code != http.StatusUnauthorized {
		t.Fatalf("without a dashboard login, a password login must not sign a browser in: %d", rec.Code)
	}
	var info struct {
		Required bool `json:"login_required"`
	}
	rec := call(http.MethodGet, "/api/session", "", nil)
	if json.Unmarshal(rec.Body.Bytes(), &info) != nil || !info.Required {
		t.Fatalf("the page must ask for a sign-in: %s", rec.Body.String())
	}

	// A sign-in link from the app opens it for the browser.
	rec = call(http.MethodPost, "/api/session/link", "phone-token", nil)
	var link struct {
		Path string `json:"path"`
	}
	_ = json.Unmarshal(rec.Body.Bytes(), &link)
	rec = call(http.MethodGet, link.Path, "", nil)
	var session *http.Cookie
	for _, c := range rec.Result().Cookies() {
		if c.Name == sessionCookieName {
			session = c
		}
	}
	if session == nil {
		t.Fatal("the link gave no session cookie")
	}
	if rec := call(http.MethodGet, "/api/accounts", "", session); rec.Code != http.StatusOK {
		t.Fatalf("the signed-in browser must reach the API: %d", rec.Code)
	}
}