package colabauth
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/require"
)
func poolCredential(subject string) credentials {
return credentials{
Type: "authorized_user", ClientID: "client", ClientSecret: "secret",
RefreshToken: "refresh-" + subject, GoogleSubject: subject, Email: subject + "@example.com",
}
}
func TestMultipleAccountBrowserFlow(t *testing.T) {
e := newEnv(t, "")
c := e.browser(owner)
signIn := func(profile, refresh string) {
e.profile, e.refresh = profile, refresh
state := e.start(c).Query().Get("state")
require.Equal(t, http.StatusFound, e.callback(c, "code=code&state="+state).StatusCode)
}
signIn(`{"sub":"first","email":"first@example.com","email_verified":true}`, "refresh-first")
store, err := e.h.readAccounts()
require.NoError(t, err)
first := store.Accounts[0].AccountID
signIn(`{"sub":"second","email":"second@example.com","email_verified":true}`, "refresh-second")
signIn(`{"sub":"first","email":"renamed@example.com","email_verified":true}`, "refresh-new")
store, err = e.h.readAccounts()
require.NoError(t, err)
require.Len(t, store.Accounts, 2)
require.Equal(t, first, store.Accounts[0].AccountID)
require.Equal(t, "refresh-new", store.Accounts[0].RefreshToken)
require.Equal(t, "refresh-second", store.Accounts[1].RefreshToken)
require.NotEqual(t, first, store.Accounts[1].AccountID)
page, err := c.Get(e.srv.URL + PageEndpoint)
require.NoError(t, err)
html := body(t, page)
require.Contains(t, html, "renamed@example.com")
require.Contains(t, html, "second@example.com")
for _, secret := range []string{"refresh-new", "refresh-second", "csecret", `value="at"`} {
require.NotContains(t, html, secret)
}
// a failed refresh/connection leaves both accounts untouched.
before, err := os.ReadFile(e.h.accountPath())
require.NoError(t, err)
e.refresh = ""
state := e.start(c).Query().Get("state")
require.Equal(t, http.StatusBadGateway, e.callback(c, "code=code&state="+state).StatusCode)
after, err := os.ReadFile(e.h.accountPath())
require.NoError(t, err)
require.Equal(t, before, after)
}
func TestLegacyImportKeepsCredentialAndStableID(t *testing.T) {
e := newEnv(t, "")
legacy := poolCredential("legacy")
legacy.GoogleSubject = ""
require.NoError(t, writePrivateJSON(e.path, legacy))
original, err := os.ReadFile(e.path)
require.NoError(t, err)
require.NoError(t, e.h.initializeAccounts())
store, err := e.h.readAccounts()
require.NoError(t, err)
require.Len(t, store.Accounts, 1)
id := store.Accounts[0].AccountID
require.Regexp(t, accountIDPattern, id)
require.NoError(t, e.h.initializeAccounts())
upgraded := poolCredential("google-subject")
upgraded.Email = strings.ToUpper(legacy.Email)
require.NoError(t, e.h.save(upgraded))
store, err = e.h.readAccounts()
require.NoError(t, err)
require.Len(t, store.Accounts, 1)
require.Equal(t, id, store.Accounts[0].AccountID)
require.Equal(t, upgraded.GoogleSubject, store.Accounts[0].GoogleSubject)
after, err := os.ReadFile(e.path)
require.NoError(t, err)
require.Equal(t, original, after)
removed, err := e.h.removeAccount(id)
require.NoError(t, err)
require.True(t, removed)
require.NoError(t, e.h.initializeAccounts())
store, err = e.h.readAccounts()
require.NoError(t, err)
require.Empty(t, store.Accounts, "empty pool must not resurrect rollback credential")
require.NoError(t, e.h.save(upgraded))
store, err = e.h.readAccounts()
require.NoError(t, err)
require.NotEqual(t, id, store.Accounts[0].AccountID, "removal invalidates old handles permanently")
}
func TestConcurrentAccountsAndPoolCap(t *testing.T) {
e := newEnv(t, "")
var wg sync.WaitGroup
errs := make(chan error, maxAccounts)
for i := 0; i < maxAccounts; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
errs <- e.h.save(poolCredential(fmt.Sprint(i)))
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
require.NoError(t, err)
}
store, err := e.h.readAccounts()
require.NoError(t, err)
require.Len(t, store.Accounts, maxAccounts)
before, err := os.ReadFile(e.h.accountPath())
require.NoError(t, err)
require.ErrorIs(t, e.h.save(poolCredential("overflow")), errPoolFull)
after, err := os.ReadFile(e.h.accountPath())
require.NoError(t, err)
require.Equal(t, before, after)
updated := poolCredential("0")
updated.RefreshToken = "new-refresh"
require.NoError(t, e.h.save(updated), "reconnect remains possible at the cap")
info, err := os.Stat(e.h.accountPath())
require.NoError(t, err)
require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
}
func TestRemoveRequiresOwnerCSRFAndOrigin(t *testing.T) {
e := newEnv(t, "")
require.NoError(t, e.h.save(poolCredential("one")))
store, err := e.h.readAccounts()
require.NoError(t, err)
id := store.Accounts[0].AccountID
c := e.browser(owner)
csrf := e.csrf(c)
post := func(client *http.Client, token, origin string) int {
req, _ := http.NewRequest(http.MethodPost, e.srv.URL+PageEndpoint+"/remove", strings.NewReader(url.Values{"csrf": {token}, "account_id": {id}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", origin)
res, err := client.Do(req)
require.NoError(t, err)
res.Body.Close()
return res.StatusCode
}
require.Equal(t, http.StatusForbidden, post(e.browser("other"), csrf, "https://gw.example"))
require.Equal(t, http.StatusForbidden, post(c, "wrong", "https://gw.example"))
require.Equal(t, http.StatusForbidden, post(c, csrf, "null"))
require.Equal(t, http.StatusForbidden, post(c, csrf, "https://evil.example"))
require.Equal(t, http.StatusSeeOther, post(c, csrf, "https://gw.example"))
require.Equal(t, http.StatusNotFound, post(c, csrf, "https://gw.example"))
store, err = e.h.readAccounts()
require.NoError(t, err)
require.Empty(t, store.Accounts)
}
func TestStoreFailuresDoNotOverwriteAccounts(t *testing.T) {
e := newEnv(t, "")
require.NoError(t, e.h.save(poolCredential("one")))
before, err := os.ReadFile(e.h.accountPath())
require.NoError(t, err)
require.NoError(t, os.Chmod(e.h.accountPath(), 0o644))
require.Error(t, e.h.save(poolCredential("two")))
after, err := os.ReadFile(e.h.accountPath())
require.NoError(t, err)
require.Equal(t, before, after)
require.NoError(t, os.Chmod(e.h.accountPath(), 0o600))
for _, invalid := range []string{`{}`, `{"version":2,"accounts":[]}`, `{"version":1,"accounts":null}`, strings.Repeat("x", maxAccountBytes+1)} {
require.NoError(t, os.WriteFile(e.h.accountPath(), []byte(invalid), 0o600))
require.Error(t, e.h.initializeAccounts())
require.Error(t, e.h.save(poolCredential("two")))
after, err := os.ReadFile(e.h.accountPath())
require.NoError(t, err)
require.Equal(t, invalid, string(after))
}
}
func TestStoreRejectsDuplicateIDs(t *testing.T) {
e := newEnv(t, "")
require.NoError(t, e.h.save(poolCredential("one")))
store, err := e.h.readAccounts()
require.NoError(t, err)
store.Accounts = append(store.Accounts, store.Accounts[0])
data, err := json.Marshal(store)
require.NoError(t, err)
require.NoError(t, os.WriteFile(e.h.accountPath(), data, 0o600))
_, err = e.h.readAccounts()
require.Error(t, err)
}