Skip to content
quarry Artifact [a67cc35a8b]
Login

Artifact [a67cc35a8b]

Artifact a67cc35a8b9bedb06d1f1fd6f7f17695b7e92f668d73c61a2e06d729c05dd26f:


package web

import (
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"sync"
	"testing"
	"time"

	"craftdesign.group/quarry/internal/api"
	"craftdesign.group/quarry/internal/ops"
)

// betaAccts is an in-memory AccountStore with the private-beta half:
// enough of a forge to invite, claim, shut and audit.
type betaAccts struct {
	mu       sync.Mutex
	accts    map[string]*api.Account
	pw       map[string]string
	invites  map[string]string // token -> account
	sessions []api.SessionInfo
}

func newBetaAccts() *betaAccts {
	return &betaAccts{accts: map[string]*api.Account{}, pw: map[string]string{}, invites: map[string]string{}}
}
func (b *betaAccts) Accounts() ([]api.Account, error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	var out []api.Account
	for _, a := range b.accts {
		out = append(out, *a)
	}
	return out, nil
}
func (b *betaAccts) AddAccount(name, password string, admin bool) error {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.accts[name] = &api.Account{Name: name, Admin: admin}
	b.pw[name] = password
	return nil
}
func (b *betaAccts) SetPassword(name, password string) error { b.pw[name] = password; return nil }
func (b *betaAccts) DeleteAccount(string) error              { return nil }
func (b *betaAccts) Grant(string, string, string) error      { return nil }
func (b *betaAccts) Revoke(string, string) error             { return nil }
func (b *betaAccts) GrantsFor(string) (map[string]string, error) {
	return map[string]string{}, nil
}
func (b *betaAccts) InviteAccount(name, email string, admin bool) (string, error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.accts[name] = &api.Account{Name: name, Admin: admin, Email: email, Pending: true}
	tok := strings.Repeat("ab", 32)
	b.invites[tok] = name
	return tok, nil
}
func (b *betaAccts) InviteFor(token string) (string, bool) {
	b.mu.Lock()
	defer b.mu.Unlock()
	n, ok := b.invites[token]
	return n, ok
}
func (b *betaAccts) RedeemInvite(token, password string) (string, error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	n, ok := b.invites[token]
	if !ok {
		return "", ops.ErrNotFound
	}
	delete(b.invites, token)
	b.pw[n] = password
	b.accts[n].Pending = false
	return n, nil
}
func (b *betaAccts) SetDisabled(name string, on bool) error {
	b.mu.Lock()
	defer b.mu.Unlock()
	a, ok := b.accts[name]
	if !ok {
		return ops.ErrNotFound
	}
	if on {
		a.Disabled = "2026-09-03T10:00:00Z"
	} else {
		a.Disabled = ""
	}
	return nil
}
func (b *betaAccts) Sessions(name string) ([]api.SessionInfo, error) {
	var out []api.SessionInfo
	for _, s := range b.sessions {
		if name == "" || s.Account == name {
			out = append(out, s)
		}
	}
	return out, nil
}
func (b *betaAccts) EndSession(id, account string) error {
	var keep []api.SessionInfo
	for _, s := range b.sessions {
		if s.ID == id && (account == "" || s.Account == account) {
			continue
		}
		keep = append(keep, s)
	}
	b.sessions = keep
	return nil
}

// betaSessions signs in anyone whose password the store knows, and
// records the device like the real plane does.
type betaSessions struct {
	accts *betaAccts
	admin string
	from  []string
}

func (s *betaSessions) Login(user, password string) (string, time.Time, bool) {
	return s.LoginFrom(user, password, "", "")
}
func (s *betaSessions) LoginFrom(user, password, ip, agent string) (string, time.Time, bool) {
	if pw, ok := s.accts.pw[user]; !ok || pw != password || s.accts.accts[user].Disabled != "" {
		return "", time.Time{}, false
	}
	s.from = append(s.from, ip+" "+agent)
	return "tok-" + user, time.Now().Add(time.Hour), true
}
func (s *betaSessions) Resolve(tok string) (string, bool) {
	u, ok := strings.CutPrefix(tok, "tok-")
	return u, ok
}
func (s *betaSessions) Logout(string) {}
func (s *betaSessions) Allow(user, repo string, need ops.Auth) bool {
	if user == "" {
		return false
	}
	if need == ops.Admin {
		return user == s.admin
	}
	return true
}

type betaCaps struct{ admin string }

func (c betaCaps) CapsOf(account, _ string) CapSet {
	if account == c.admin {
		return stubCaps{letters: "s"}
	}
	return stubCaps{letters: ""}
}

func betaForge(t *testing.T) (*httptest.Server, *betaAccts, *betaSessions) {
	t.Helper()
	accts := newBetaAccts()
	_ = accts.AddAccount("root", "root-pw", true)
	sess := &betaSessions{accts: accts, admin: "root"}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v", Accounts: accts,
		Repos: api.NewRepos(map[string]*api.Repo{})}); err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(HandlerWith(r, nil, nil,
		[]Option{WithSessions(sess), WithCaps(betaCaps{admin: "root"})},
		Verify(func(string, string, string, ops.Auth) api.Decision { return api.Denied })))
	t.Cleanup(srv.Close)
	return srv, accts, sess
}

func as(t *testing.T, srv *httptest.Server, user, method, path string, form url.Values) *http.Response {
	t.Helper()
	var req *http.Request
	if method == "POST" {
		req, _ = http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode()))
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	} else {
		req, _ = http.NewRequest("GET", srv.URL+path, nil)
	}
	if user != "" {
		req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "tok-" + user})
	}
	req.Header.Set("User-Agent", "Mozilla/5.0 (iPhone) Safari")
	client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
	res, err := client.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	return res
}

func body(res *http.Response) string {
	defer res.Body.Close()
	var sb strings.Builder
	buf := make([]byte, 64<<10)
	for {
		n, err := res.Body.Read(buf)
		sb.Write(buf[:n])
		if err != nil {
			break
		}
	}
	return sb.String()
}

// The private beta's front desk: an admin invites a person from the
// People tab and gets the link; the person opens it, sets a password,
// and is signed in; the admin can then see them, shut them out, and
// they cannot sign in again until re-enabled.
func TestInviteClaimDisableRoundTrip(t *testing.T) {
	srv, accts, sess := betaForge(t)

	// Not an admin: no People tab.
	if res := as(t, srv, "", "GET", "/fleet/people", nil); res.StatusCode == 200 {
		t.Fatalf("anonymous reached the People tab")
	}
	res := as(t, srv, "root", "POST", "/fleet/people/invite", url.Values{"name": {"eve"}, "email": {"[email protected]"}})
	body(res)
	if res.StatusCode != 303 {
		t.Fatalf("invite = %d", res.StatusCode)
	}
	// The flash rides the redirect; follow it with the cookie jar's help.
	req, _ := http.NewRequest("GET", srv.URL+"/fleet/people", nil)
	req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "tok-root"})
	for _, c := range res.Cookies() {
		req.AddCookie(c)
	}
	res2, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	page := body(res2)
	token := strings.Repeat("ab", 32)
	if !strings.Contains(page, "/invite/"+token) {
		t.Fatalf("the People tab does not show the invite link:\n%s", page[:min(len(page), 3000)])
	}
	if !strings.Contains(page, ">invited<") || !strings.Contains(page, "[email protected]") {
		t.Errorf("the invited account is not listed as invited with its email")
	}

	// The person opens the link.
	page = body(as(t, srv, "", "GET", "/invite/"+token, nil))
	if !strings.Contains(page, "<strong>eve</strong>") || !strings.Contains(page, `name="password"`) {
		t.Fatalf("invite page does not greet eve with a password form")
	}
	// Mismatched passwords are refused; a short one too.
	if res := as(t, srv, "", "POST", "/invite/"+token, url.Values{"password": {"longenough1"}, "again": {"different1"}}); res.StatusCode != 400 {
		t.Errorf("mismatch accepted: %d", res.StatusCode)
	}
	res = as(t, srv, "", "POST", "/invite/"+token, url.Values{"password": {"longenough1"}, "again": {"longenough1"}})
	body(res)
	if res.StatusCode != 303 || res.Header.Get("Location") != "/" {
		t.Fatalf("claim = %d -> %q, want 303 to /", res.StatusCode, res.Header.Get("Location"))
	}
	var signed bool
	for _, c := range res.Cookies() {
		if c.Name == sessionCookie && c.Value == "tok-eve" {
			signed = true
		}
	}
	if !signed {
		t.Errorf("claiming did not sign eve in")
	}
	if len(sess.from) == 0 || !strings.Contains(sess.from[len(sess.from)-1], "iPhone") {
		t.Errorf("the sign-in did not record the device: %v", sess.from)
	}
	if accts.accts["eve"].Pending {
		t.Errorf("eve still pending after claiming")
	}
	// A spent invite is gone.
	if res := as(t, srv, "", "GET", "/invite/"+token, nil); res.StatusCode != 404 {
		t.Errorf("spent invite = %d, want 404", res.StatusCode)
	}
	// Ordinary sign-in from another device works with the password she set.
	res = as(t, srv, "", "POST", "/login", url.Values{"user": {"eve"}, "password": {"longenough1"}})
	body(res)
	if res.StatusCode != 303 {
		t.Errorf("eve cannot sign in from a second device: %d", res.StatusCode)
	}

	// The admin shuts the door.
	body(as(t, srv, "root", "POST", "/fleet/people/disable", url.Values{"name": {"eve"}, "on": {"true"}}))
	if accts.accts["eve"].Disabled == "" {
		t.Fatalf("disable did not shut the account")
	}
	res = as(t, srv, "", "POST", "/login", url.Values{"user": {"eve"}, "password": {"longenough1"}})
	body(res)
	if res.StatusCode != 401 {
		t.Errorf("a disabled account signed in: %d", res.StatusCode)
	}
	page = body(as(t, srv, "root", "GET", "/fleet/people", nil))
	if !strings.Contains(page, ">disabled<") || !strings.Contains(page, "Re-enable") {
		t.Errorf("the People tab does not show eve as disabled with a re-enable control")
	}
	body(as(t, srv, "root", "POST", "/fleet/people/disable", url.Values{"name": {"eve"}, "on": {"false"}}))
	res = as(t, srv, "", "POST", "/login", url.Values{"user": {"eve"}, "password": {"longenough1"}})
	body(res)
	if res.StatusCode != 303 {
		t.Errorf("a re-enabled account cannot sign in: %d", res.StatusCode)
	}
	// Nobody can shut their own account.
	body(as(t, srv, "root", "POST", "/fleet/people/disable", url.Values{"name": {"root"}, "on": {"true"}}))
	if accts.accts["root"].Disabled != "" {
		t.Errorf("the admin locked themself out")
	}
}

// Sessions are auditable: the fleet sees everyone's, a person sees
// their own and can end one that is not the device in hand.
func TestSessionAuditPages(t *testing.T) {
	srv, accts, _ := betaForge(t)
	accts.sessions = []api.SessionInfo{
		{ID: "aaaaaaaaaaaa", Account: "root", IP: "10.0.0.1", Agent: "Mozilla/5.0 (Macintosh) Chrome", Created: "2026-09-03T09:00:00Z", LastSeen: "2026-09-03T09:30:00Z"},
		{ID: "bbbbbbbbbbbb", Account: "eve", IP: "10.0.0.2", Agent: "Mozilla/5.0 (iPhone) Safari", Created: "2026-09-03T09:10:00Z"},
	}
	_ = accts.AddAccount("eve", "pw", false)
	page := body(as(t, srv, "root", "GET", "/fleet/people", nil))
	for _, want := range []string{"10.0.0.1", "10.0.0.2", "Chrome on Mac", "Safari on iPhone", `value="bbbbbbbbbbbb"`, "1 device"} {
		if !strings.Contains(page, want) {
			t.Errorf("fleet audit lacks %q", want)
		}
	}
	// eve sees only her own row and cannot see root's address.
	page = body(as(t, srv, "eve", "GET", "/me/sessions", nil))
	if strings.Contains(page, "10.0.0.1") || !strings.Contains(page, "10.0.0.2") {
		t.Errorf("a person's session page shows the wrong rows")
	}
	// Ending a session by id, as the admin, removes it.
	body(as(t, srv, "root", "POST", "/fleet/sessions/end", url.Values{"id": {"bbbbbbbbbbbb"}}))
	if len(accts.sessions) != 1 || accts.sessions[0].Account != "root" {
		t.Errorf("session.end did not remove eve's session: %+v", accts.sessions)
	}
}

// Password guessing is slowed: after the free misses a name is held,
// and a success clears the count.
func TestLoginThrottle(t *testing.T) {
	l := newLoginLimiter()
	for i := 0; i < loginFreeMisses; i++ {
		if ok, _ := l.allowed("dan", "1.2.3.4"); !ok {
			t.Fatalf("held after %d misses", i)
		}
		l.failed("dan", "1.2.3.4")
	}
	l.failed("dan", "1.2.3.4")
	if ok, wait := l.allowed("dan", "1.2.3.4"); ok || wait <= 0 {
		t.Fatalf("not held after %d misses", loginFreeMisses+1)
	}
	// The same address is held for any name; another address for the
	// same name is held too.
	if ok, _ := l.allowed("someone-else", "1.2.3.4"); ok {
		t.Errorf("the address is not held")
	}
	if ok, _ := l.allowed("dan", "5.6.7.8"); ok {
		t.Errorf("the account is not held")
	}
	l.succeeded("dan", "1.2.3.4")
	if ok, _ := l.allowed("dan", "1.2.3.4"); !ok {
		t.Errorf("a success did not clear the hold")
	}
	srv, _, _ := betaForge(t)
	var last int
	for i := 0; i < loginFreeMisses+2; i++ {
		res := as(t, srv, "", "POST", "/login", url.Values{"user": {"root"}, "password": {"wrong"}})
		body(res)
		last = res.StatusCode
	}
	if last != http.StatusTooManyRequests {
		t.Errorf("after repeated misses the login answers %d, want 429", last)
	}
}