195 lines
7.6 KiB
Go
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(runHeartbeatStaleAfter / 2) })
|
|
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))
|
|
}
|