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)) }