Files
browser/platform/service/auth_sessions_test.go
T

195 lines
7.6 KiB
Go

package service
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
func TestAuthSessionPersistsWithoutRawTokenAndRotates(t *testing.T) {
path := filepath.Join(t.TempDir(), "metadata.json")
store, err := repo.NewFileStore(path)
if err != nil {
t.Fatalf("create file store: %v", err)
}
now := time.Date(2026, 7, 18, 8, 0, 0, 0, time.UTC)
svc := newCoreService(store, func() time.Time { return now })
user, err := svc.CreateUser(domain.User{
ID: "user-owner", DisplayName: "Owner", Email: "owner@example.test",
Status: domain.UserStatusActive, Roles: []string{"server-admin"}, PasswordHash: "secret-password",
})
if err != nil {
t.Fatalf("create user: %v", err)
}
session, err := svc.LoginUser(domain.UserLogin{Account: user.Email, Password: "secret-password"})
if err != nil {
t.Fatalf("login: %v", err)
}
if session.SessionID == "" || !session.ExpiresAt.Equal(now.Add(defaultAuthSessionTTL)) {
t.Fatalf("unexpected bounded session: %+v", session)
}
payload, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read snapshot: %v", err)
}
if strings.Contains(string(payload), session.SessionID) || strings.Contains(string(payload), "secret-password") {
t.Fatalf("snapshot contains raw session or password literal: %s", payload)
}
if !strings.Contains(string(payload), tokenHash(session.SessionID)) {
t.Fatalf("snapshot does not contain the expected one-way session verifier")
}
reloadedStore, err := repo.NewFileStore(path)
if err != nil {
t.Fatalf("reload file store: %v", err)
}
reloaded := newCoreService(reloadedStore, func() time.Time { return now.Add(time.Minute) })
if current, err := reloaded.GetCurrentUser(session.SessionID); err != nil || current.ID != user.ID {
t.Fatalf("restored session was not accepted: current=%+v err=%v", current, err)
}
rotated, err := reloaded.RotateUserSession(session.SessionID)
if err != nil {
t.Fatalf("rotate session: %v", err)
}
if rotated.SessionID == "" || rotated.SessionID == session.SessionID {
t.Fatalf("rotation did not issue a distinct token")
}
if _, err := reloaded.GetCurrentUser(session.SessionID); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("revoked prior token should be unauthorized, got %v", err)
}
if current, err := reloaded.GetCurrentUser(rotated.SessionID); err != nil || current.ID != user.ID {
t.Fatalf("rotated token was not accepted: current=%+v err=%v", current, err)
}
}
func TestAuthSessionExpiryIsDurablyRevoked(t *testing.T) {
store := repo.NewMemoryStore()
now := time.Date(2026, 7, 18, 8, 0, 0, 0, time.UTC)
svc := newCoreService(store, func() time.Time { return now })
if _, err := svc.CreateUser(domain.User{ID: "user-expiry", DisplayName: "Expiry", Email: "expiry@example.test", Status: domain.UserStatusActive, Roles: []string{"server-admin"}, PasswordHash: "secret-password"}); err != nil {
t.Fatalf("create user: %v", err)
}
session, err := svc.LoginUser(domain.UserLogin{Account: "expiry@example.test", Password: "secret-password"})
if err != nil {
t.Fatalf("login: %v", err)
}
now = now.Add(defaultAuthSessionTTL + time.Second)
if _, err := svc.GetCurrentUser(session.SessionID); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("expired session should be unauthorized, got %v", err)
}
records, err := store.AuthSessions().List(domain.AuthSessionFilter{TokenHash: tokenHash(session.SessionID)})
if err != nil || len(records) != 1 || records[0].Status != domain.AuthSessionStatusRevoked || records[0].RevokedAt.IsZero() {
t.Fatalf("expired session was not durably revoked: records=%+v err=%v", records, err)
}
}
func TestDisablingUserRevokesActiveSessions(t *testing.T) {
store := repo.NewMemoryStore()
svc := NewCoreService(store)
user, err := svc.CreateUser(domain.User{ID: "user-disabled", DisplayName: "Disabled", Email: "disabled@example.test", Status: domain.UserStatusActive, Roles: []string{"server-admin"}, PasswordHash: "secret-password"})
if err != nil {
t.Fatalf("create user: %v", err)
}
session, err := svc.LoginUser(domain.UserLogin{Account: user.Email, Password: "secret-password"})
if err != nil {
t.Fatalf("login: %v", err)
}
user.Status = domain.UserStatusDisabled
if _, err := svc.UpdateUser(user.ID, user); err != nil {
t.Fatalf("disable user: %v", err)
}
if _, err := svc.GetCurrentUser(session.SessionID); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("disabled user session should be unauthorized, got %v", err)
}
records, err := store.AuthSessions().List(domain.AuthSessionFilter{UserID: user.ID})
if err != nil || len(records) != 1 || records[0].Status != domain.AuthSessionStatusRevoked || records[0].RevokedAt.IsZero() {
t.Fatalf("disabled user sessions were not revoked: records=%+v err=%v", records, err)
}
}
func TestRunSessionPersistsAndSignedEnvelopeRejectsReplay(t *testing.T) {
path := filepath.Join(t.TempDir(), "metadata.json")
store, err := repo.NewFileStore(path)
if err != nil {
t.Fatalf("create store: %v", err)
}
now := time.Date(2026, 7, 18, 8, 0, 0, 0, time.UTC)
svc := newCoreService(store, func() time.Time { return now })
hello, err := svc.RegisterRunHello(validRunControlHello())
if err != nil {
t.Fatalf("register run: %v", err)
}
record, err := store.RunControlSessions().Get("run-local")
if err != nil {
t.Fatalf("get run session: %v", err)
}
record.RequireSignedRequests = true
if err := store.RunControlSessions().Update(record); err != nil {
t.Fatalf("require signed requests: %v", err)
}
delete(svc.runSessions, "run-local")
request := domain.RunRequestSignature{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Method: "POST",
Path: "/api/v1/run/jobs/claim",
Timestamp: strconv.FormatInt(now.Unix(), 10),
Nonce: "nonce-1",
BodyHash: strings.Repeat("a", 64),
}
request.Signature = signRunRequest(request)
if err := svc.AuthorizeRunRequestSignature(request); err != nil {
t.Fatalf("authorize signed request: %v", err)
}
if err := svc.AuthorizeRunRequestSignature(request); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("replayed nonce should be unauthorized, got %v", err)
}
stale := request
stale.Nonce = "nonce-2"
stale.Timestamp = strconv.FormatInt(now.Add(-maxRunRequestClockSkew-time.Second).Unix(), 10)
stale.Signature = signRunRequest(stale)
if err := svc.AuthorizeRunRequestSignature(stale); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("stale signature should be unauthorized, got %v", err)
}
reloadedStore, err := repo.NewFileStore(path)
if err != nil {
t.Fatalf("reload store: %v", err)
}
reloaded := newCoreService(reloadedStore, func() time.Time { return now.Add(time.Minute) })
result, err := reloaded.AcceptRunHeartbeat(domain.RunControlHeartbeat{
RunEndpointID: "run-local", SessionToken: hello.SessionToken, Version: "0.1.1",
Status: domain.RunEndpointStatusOnline, CapabilityFingerprint: "cap-jobs",
Capacity: domain.RunCapacity{MaxJobs: 4},
})
if err != nil || !result.Accepted {
t.Fatalf("reloaded hashed Run session was not accepted: result=%+v err=%v", result, err)
}
payload, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read snapshot: %v", err)
}
if strings.Contains(string(payload), hello.SessionToken) || strings.Contains(string(payload), "registration-token") {
t.Fatalf("snapshot contains raw Run credential: %s", payload)
}
}
func signRunRequest(request domain.RunRequestSignature) string {
canonical := strings.Join([]string{request.Method, request.Path, request.Timestamp, request.Nonce, request.BodyHash}, "\n")
mac := hmac.New(sha256.New, []byte(request.SessionToken))
_, _ = mac.Write([]byte(canonical))
return hex.EncodeToString(mac.Sum(nil))
}