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
resets map[string]bool // token -> minted by a reset rather than an invite
sessions []api.SessionInfo
emailTokens map[string]string // account -> pending code, for the AccountEmail half
pwEnded map[string]bool // account -> SetPasswordSelf ended its other sessions
pwKept map[string]string // account -> last "keep" SetPasswordSelf saw
}
func newBetaAccts() *betaAccts {
return &betaAccts{accts: map[string]*api.Account{}, pw: map[string]string{},
invites: map[string]string{}, resets: map[string]bool{}}
}
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) SetOperator(string, string) error { return nil }
func (b *betaAccts) DeleteAccount(string) error { return nil }
func (b *betaAccts) Grant(string, string, string, string) (string, error) { return "", nil }
func (b *betaAccts) Revoke(string, string, 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) ResetPassword(name string) (string, error) {
b.mu.Lock()
defer b.mu.Unlock()
a, ok := b.accts[name]
if !ok {
return "", ops.ErrNotFound
}
if a.Pending {
return "", ops.ErrInvalid
}
tok := strings.Repeat("cd", 32)
b.invites[tok] = name
b.resets[tok] = true
return tok, nil
}
func (b *betaAccts) TokenKind(token string) string {
b.mu.Lock()
defer b.mu.Unlock()
if b.resets[token] {
return "reset"
}
return "invite"
}
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) EndSessions(account string) error {
var keep []api.SessionInfo
for _, s := range b.sessions {
if s.Account != account {
keep = append(keep, s)
}
}
b.sessions = keep
return nil
}
// The confirmed-address half (api.AccountEmail), enough for the
// account-email section and the fleet row's control to exercise
// against a real op registration rather than a bespoke stub per test.
func (b *betaAccts) AccountByName(name string) (api.Account, bool) {
b.mu.Lock()
defer b.mu.Unlock()
a, ok := b.accts[name]
if !ok {
return api.Account{}, false
}
return *a, true
}
func (b *betaAccts) SetEmail(name, email string) error {
b.mu.Lock()
defer b.mu.Unlock()
a, ok := b.accts[name]
if !ok {
return ops.ErrNotFound
}
a.Email = email
return nil
}
func (b *betaAccts) SetPendingEmail(name, address string) (string, error) {
b.mu.Lock()
defer b.mu.Unlock()
a, ok := b.accts[name]
if !ok {
return "", ops.ErrNotFound
}
a.EmailPending = address
if b.emailTokens == nil {
b.emailTokens = map[string]string{}
}
// A fixed six-digit code, deterministic per account so a test can
// name it without reading the fake's internals.
b.emailTokens[name] = "654321"
return b.emailTokens[name], nil
}
func (b *betaAccts) ConfirmEmail(account, code string) (string, bool, error) {
b.mu.Lock()
defer b.mu.Unlock()
want, ok := b.emailTokens[account]
if !ok || want != code {
return "", false, nil
}
a := b.accts[account]
a.Email, a.EmailPending = a.EmailPending, ""
delete(b.emailTokens, account)
return a.Email, true, nil
}
func (b *betaAccts) ClearEmail(name string) error {
b.mu.Lock()
defer b.mu.Unlock()
a, ok := b.accts[name]
if !ok {
return ops.ErrNotFound
}
a.Email, a.EmailPending = "", ""
return nil
}
// The self-service credential half (api.AccountAuth), for /me/passwd.
func (b *betaAccts) VerifyPassword(name, password string) bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.pw[name] != "" && b.pw[name] == password
}
func (b *betaAccts) SetPasswordSelf(name, password, keep string) error {
b.mu.Lock()
defer b.mu.Unlock()
if _, ok := b.accts[name]; !ok {
return ops.ErrNotFound
}
b.pw[name] = password
if b.pwEnded == nil {
b.pwEnded = map[string]bool{}
}
if b.pwKept == nil {
b.pwKept = map[string]string{}
}
b.pwEnded[name] = true
b.pwKept[name] = keep
var keptSessions []api.SessionInfo
for _, s := range b.sessions {
if s.Account != name || s.ID == keep {
keptSessions = append(keptSessions, s)
}
}
b.sessions = keptSessions
return 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
out []string // tokens Logout was asked to end
}
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(t string) { s.out = append(s.out, t) }
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)
}
// Follow the redirect, query and all: the invite link rides the
// vault under a single-use nonce now, not the flash.
req, _ := http.NewRequest("GET", srv.URL+res.Header.Get("Location"), 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. /me is
// the page now; /me/sessions is only a redirect to it.
page = body(as(t, srv, "eve", "GET", "/me", 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)
}
// Sign out everywhere, per account, from the People tab.
accts.sessions = append(accts.sessions, api.SessionInfo{ID: "cccccccccccc", Account: "eve"}, api.SessionInfo{ID: "dddddddddddd", Account: "eve"})
if !strings.Contains(body(as(t, srv, "root", "GET", "/fleet/people", nil)), "Sign out everywhere") {
t.Errorf("no sign-out-everywhere control")
}
body(as(t, srv, "root", "POST", "/fleet/sessions/end", url.Values{"account": {"eve"}}))
if len(accts.sessions) != 1 || accts.sessions[0].Account != "root" {
t.Errorf("sign out everywhere left %+v", accts.sessions)
}
// A person cannot sign out somebody else everywhere.
accts.sessions = append(accts.sessions, api.SessionInfo{ID: "eeeeeeeeeeee", Account: "root"})
body(as(t, srv, "eve", "POST", "/me/sessions/end", url.Values{"account": {"root"}}))
if len(accts.sessions) != 2 {
t.Errorf("eve signed root out: %+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)
}
}