mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 22:34:47 +00:00
* feat(helm): add Pipelock ConfigMap, scanning config, and consolidate compose - Add ConfigMap template rendering DLP, response scanning, MCP input/tool scanning, and forward proxy settings from values - Mount ConfigMap as /etc/pipelock/pipelock.yaml volume in deployment - Add checksum/config annotation for automatic pod restart on config change - Gate HTTPS_PROXY/HTTP_PROXY env injection on forwardProxy.enabled (skip in MCP-only mode) - Use hasKey for all boolean values to prevent Helm default swallowing false - Single source of truth for ports (forwardProxy.port/mcpProxy.port) - Pipelock-specific imagePullSecrets with fallback to app secrets - Merge standalone compose.example.pipelock.yml into compose.example.ai.yml - Add pipelock.example.yaml for Docker Compose users - Add exclude-paths to CI workflow for locale file false positives * Add external assistant support (OpenAI-compatible SSE proxy) Allow self-hosted instances to delegate chat to an external AI agent via an OpenAI-compatible streaming endpoint. Configurable per-family through Settings UI or ASSISTANT_TYPE env override. - Assistant::External::Client: SSE streaming HTTP client (no new gems) - Settings UI with type selector, env lock indicator, config status - Helm chart and Docker Compose env var support - 45 tests covering client, config, routing, controller, integration * Add session key routing, email allowlist, and config plumbing Route to the actual OpenClaw session via x-openclaw-session-key header instead of creating isolated sessions. Gate external assistant access behind an email allowlist (EXTERNAL_ASSISTANT_ALLOWED_EMAILS env var). Plumb session_key and allowedEmails through Helm chart, compose, and env template. * Add HTTPS_PROXY support to External::Client for Pipelock integration Net::HTTP does not auto-read HTTPS_PROXY/HTTP_PROXY env vars (unlike Faraday). Explicitly resolve proxy from environment in build_http so outbound traffic to the external assistant routes through Pipelock's forward proxy when enabled. Respects NO_PROXY for internal hosts. * Add UI fields for external assistant config (Setting-backed with env fallback) Follow the same pattern as OpenAI settings: database-backed Setting fields with env var defaults. Self-hosters can now configure the external assistant URL, token, and agent ID from the browser (Settings > Self-Hosting > AI Assistant) instead of requiring env vars. Fields disable when the corresponding env var is set. * Improve external assistant UI labels and add help text Change placeholder to generic OpenAI-compatible URL pattern. Add help text under each field explaining where the values come from: URL from agent provider, token for authentication, agent ID for multi-agent routing. * Add external assistant docs and fix URL help text Add External AI Assistant section to docs/hosting/ai.md covering setup (UI and env vars), how it works, Pipelock security scanning, access control, and Docker Compose example. Drop "chat completions" jargon from URL help text. * Harden external assistant: retry logic, disconnect UI, error handling, and test coverage - Add retry with backoff for transient network errors (no retry after streaming starts) - Add disconnect button with confirmation modal in self-hosting settings - Narrow rescue scope with fallback logging for unexpected errors - Safe cleanup of partial responses on stream interruption - Gate ai_available? on family assistant_type instead of OR-ing all providers - Truncate conversation history to last 20 messages - Proxy-aware HTTP client with NO_PROXY support - Sanitize protocol to use generic headers (X-Agent-Id, X-Session-Key) - Full test coverage for streaming, retries, proxy routing, config, and disconnect * Exclude external assistant client from Pipelock scan-diff False positive: `@token` instance variable flagged as "Credential in URL". Temporary workaround until Pipelock supports inline suppression. * Address review feedback: NO_PROXY boundary fix, SSE done flag, design tokens - Fix NO_PROXY matching to require domain boundary (exact match or .suffix), case-insensitive. Prevents badexample.com matching example.com. - Add done flag to SSE streaming so read_body stops after [DONE] - Move MAX_CONVERSATION_MESSAGES to class level - Use bg-success/bg-destructive design tokens for status indicators - Add rationale comment for pipelock scan exclusion - Update docs last-updated date * Address second round of review feedback - Allowlist email comparison is now case-insensitive and nil-safe - Cap SSE buffer at 1 MB to prevent memory blowup from malformed streams - Don't expose upstream HTTP response body in user-facing errors (log it instead) - Fix frozen string warning on buffer initialization - Fix "builtin" typo in docs (should be "built-in") * Protect completed responses from cleanup, sanitize error messages - Don't destroy a fully streamed assistant message if post-stream metadata update fails (only cleanup partial responses) - Log raw connection/HTTP errors internally, show generic messages to users to avoid leaking network/proxy details - Update test assertions for new error message wording * Fix SSE content guard and NO_PROXY test correctness Use nil check instead of present? for SSE delta content to preserve whitespace-only chunks (newlines, spaces) that can occur in code output. Fix NO_PROXY test to use HTTP_PROXY matching the http:// client URL so the proxy resolution and NO_PROXY bypass logic are actually exercised. * Forward proxy credentials to Net::HTTP Pass proxy_uri.user and proxy_uri.password to Net::HTTP.new so authenticated proxies (http://user:pass@host:port) work correctly. Without this, credentials parsed from the proxy URL were silently dropped. Nil values are safe as positional args when no creds exist. * Update pipelock integration to v0.3.1 with full scanning config Bump Helm image tag from 0.2.7 to 0.3.1. Add missing security sections to both the Helm ConfigMap and compose example config: mcp_tool_policy, mcp_session_binding, and tool_chain_detection. These protect the /mcp endpoint against tool injection, session hijacking, and multi-step exfiltration chains. Add version and mode fields to config files. Enable include_defaults for DLP and response scanning to merge user patterns with the 35 built-in patterns. Remove redundant --mode CLI flag from the Helm deployment template since mode is now in the config file.
478 lines
16 KiB
Ruby
478 lines
16 KiB
Ruby
require "test_helper"
|
|
|
|
class UserTest < ActiveSupport::TestCase
|
|
include ActiveJob::TestHelper
|
|
|
|
def setup
|
|
@user = users(:family_admin)
|
|
end
|
|
|
|
def teardown
|
|
clear_enqueued_jobs
|
|
clear_performed_jobs
|
|
end
|
|
|
|
test "should be valid" do
|
|
assert @user.valid?, @user.errors.full_messages.to_sentence
|
|
end
|
|
|
|
# email
|
|
test "email must be present" do
|
|
potential_user = User.new(
|
|
email: "david@davidbowie.com",
|
|
password_digest: BCrypt::Password.create("password"),
|
|
first_name: "David",
|
|
last_name: "Bowie"
|
|
)
|
|
potential_user.email = " "
|
|
assert_not potential_user.valid?
|
|
end
|
|
|
|
test "has email address" do
|
|
assert_equal "bob@bobdylan.com", @user.email
|
|
end
|
|
|
|
test "can update email" do
|
|
@user.update(email: "new_email@example.com")
|
|
assert_equal "new_email@example.com", @user.email
|
|
end
|
|
|
|
test "email addresses must be unique" do
|
|
duplicate_user = @user.dup
|
|
duplicate_user.email = @user.email.upcase
|
|
@user.save
|
|
assert_not duplicate_user.valid?
|
|
end
|
|
|
|
test "email address is normalized" do
|
|
@user.update!(email: " UNIQUE-User@ExAMPle.CoM ")
|
|
assert_equal "unique-user@example.com", @user.reload.email
|
|
end
|
|
|
|
test "display name" do
|
|
user = User.new(email: "user@example.com")
|
|
assert_equal "user@example.com", user.display_name
|
|
user.first_name = "Bob"
|
|
assert_equal "Bob", user.display_name
|
|
user.last_name = "Dylan"
|
|
assert_equal "Bob Dylan", user.display_name
|
|
end
|
|
|
|
test "initial" do
|
|
user = User.new(email: "user@example.com")
|
|
assert_equal "U", user.initial
|
|
user.first_name = "Bob"
|
|
assert_equal "B", user.initial
|
|
user.first_name = nil
|
|
user.last_name = "Dylan"
|
|
assert_equal "D", user.initial
|
|
end
|
|
|
|
test "names are normalized" do
|
|
@user.update!(first_name: "", last_name: "")
|
|
assert_nil @user.first_name
|
|
assert_nil @user.last_name
|
|
|
|
@user.update!(first_name: " Bob ", last_name: " Dylan ")
|
|
assert_equal "Bob", @user.first_name
|
|
assert_equal "Dylan", @user.last_name
|
|
end
|
|
|
|
# MFA Tests
|
|
test "setup_mfa! generates required fields" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
|
|
assert user.otp_secret.present?
|
|
assert_not user.otp_required?
|
|
assert_empty user.otp_backup_codes
|
|
end
|
|
|
|
test "enable_mfa! enables MFA and generates backup codes" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
user.enable_mfa!
|
|
|
|
assert user.otp_required?
|
|
assert_equal 8, user.otp_backup_codes.length
|
|
assert user.otp_backup_codes.all? { |code| code.length == 8 }
|
|
end
|
|
|
|
test "disable_mfa! removes all MFA data" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
user.enable_mfa!
|
|
user.disable_mfa!
|
|
|
|
assert_nil user.otp_secret
|
|
assert_not user.otp_required?
|
|
assert_empty user.otp_backup_codes
|
|
end
|
|
|
|
test "verify_otp? validates TOTP codes" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
|
|
totp = ROTP::TOTP.new(user.otp_secret, issuer: "Sure Finances")
|
|
valid_code = totp.now
|
|
|
|
assert user.verify_otp?(valid_code)
|
|
assert_not user.verify_otp?("invalid")
|
|
assert_not user.verify_otp?("123456")
|
|
end
|
|
|
|
test "verify_otp? accepts backup codes" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
user.enable_mfa!
|
|
|
|
backup_code = user.otp_backup_codes.first
|
|
assert user.verify_otp?(backup_code)
|
|
|
|
# Backup code should be consumed
|
|
assert_not user.otp_backup_codes.include?(backup_code)
|
|
assert_equal 7, user.otp_backup_codes.length
|
|
|
|
# Used backup code should not work again
|
|
assert_not user.verify_otp?(backup_code)
|
|
end
|
|
|
|
test "provisioning_uri generates correct URI" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
|
|
assert_match %r{otpauth://totp/}, user.provisioning_uri
|
|
assert_match %r{secret=#{user.otp_secret}}, user.provisioning_uri
|
|
assert_match %r{issuer=Sure}, user.provisioning_uri
|
|
end
|
|
|
|
test "ai_available? returns true when openai access token set in settings" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
|
previous = Setting.openai_access_token
|
|
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do
|
|
Setting.openai_access_token = nil
|
|
assert_not @user.ai_available?
|
|
|
|
Setting.openai_access_token = "token"
|
|
assert @user.ai_available?
|
|
end
|
|
ensure
|
|
Setting.openai_access_token = previous
|
|
end
|
|
|
|
test "ai_available? returns true when external assistant is configured and family type is external" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
|
previous = Setting.openai_access_token
|
|
@user.family.update!(assistant_type: "external")
|
|
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token" do
|
|
Setting.openai_access_token = nil
|
|
assert @user.ai_available?
|
|
end
|
|
ensure
|
|
Setting.openai_access_token = previous
|
|
@user.family.update!(assistant_type: "builtin")
|
|
end
|
|
|
|
test "ai_available? returns false when external assistant is configured but family type is builtin" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
|
previous = Setting.openai_access_token
|
|
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token" do
|
|
Setting.openai_access_token = nil
|
|
assert_not @user.ai_available?
|
|
end
|
|
ensure
|
|
Setting.openai_access_token = previous
|
|
end
|
|
|
|
test "ai_available? returns false when external assistant is configured but user is not in allowlist" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
|
previous = Setting.openai_access_token
|
|
@user.family.update!(assistant_type: "external")
|
|
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token", EXTERNAL_ASSISTANT_ALLOWED_EMAILS: "other@example.com" do
|
|
Setting.openai_access_token = nil
|
|
assert_not @user.ai_available?
|
|
end
|
|
ensure
|
|
Setting.openai_access_token = previous
|
|
@user.family.update!(assistant_type: "builtin")
|
|
end
|
|
|
|
test "intro layout collapses sidebars and enables ai" do
|
|
user = User.new(
|
|
family: families(:empty),
|
|
email: "intro-new@example.com",
|
|
password: "Password1!",
|
|
password_confirmation: "Password1!",
|
|
role: :guest,
|
|
ui_layout: :intro
|
|
)
|
|
|
|
assert user.save, user.errors.full_messages.to_sentence
|
|
assert user.ui_layout_intro?
|
|
assert_not user.show_sidebar?
|
|
assert_not user.show_ai_sidebar?
|
|
assert user.ai_enabled?
|
|
end
|
|
|
|
test "non-guest role cannot persist intro layout" do
|
|
user = User.new(
|
|
family: families(:empty),
|
|
email: "dashboard-only@example.com",
|
|
password: "Password1!",
|
|
password_confirmation: "Password1!",
|
|
role: :member,
|
|
ui_layout: :intro
|
|
)
|
|
|
|
assert user.save, user.errors.full_messages.to_sentence
|
|
assert user.ui_layout_dashboard?
|
|
end
|
|
|
|
test "upgrading guest role restores dashboard layout defaults" do
|
|
user = users(:intro_user)
|
|
user.update!(role: :member)
|
|
user.reload
|
|
|
|
assert user.ui_layout_dashboard?
|
|
assert user.show_sidebar?
|
|
assert user.show_ai_sidebar?
|
|
end
|
|
|
|
test "update_dashboard_preferences handles concurrent updates atomically" do
|
|
@user.update!(preferences: {})
|
|
|
|
# Simulate concurrent updates from multiple requests
|
|
# Each thread collapses a different section simultaneously
|
|
threads = []
|
|
sections = %w[net_worth_chart outflows_donut cashflow_sankey balance_sheet]
|
|
|
|
sections.each_with_index do |section, index|
|
|
threads << Thread.new do
|
|
# Small staggered delays to increase chance of race conditions
|
|
sleep(index * 0.01)
|
|
|
|
# Each thread loads its own instance and updates
|
|
user = User.find(@user.id)
|
|
user.update_dashboard_preferences({
|
|
"collapsed_sections" => { section => true }
|
|
})
|
|
end
|
|
end
|
|
|
|
# Wait for all threads to complete
|
|
threads.each(&:join)
|
|
|
|
# Verify all updates persisted (no data loss from race conditions)
|
|
@user.reload
|
|
sections.each do |section|
|
|
assert @user.dashboard_section_collapsed?(section),
|
|
"Expected #{section} to be collapsed, but it was not. " \
|
|
"Preferences: #{@user.preferences.inspect}"
|
|
end
|
|
|
|
# Verify all sections are in the preferences hash
|
|
assert_equal sections.sort,
|
|
@user.preferences.dig("collapsed_sections")&.keys&.sort,
|
|
"Expected all sections to be in preferences"
|
|
end
|
|
|
|
test "update_dashboard_preferences merges nested hashes correctly" do
|
|
@user.update!(preferences: {})
|
|
|
|
# First update: collapse net_worth
|
|
@user.update_dashboard_preferences({
|
|
"collapsed_sections" => { "net_worth_chart" => true }
|
|
})
|
|
@user.reload
|
|
|
|
assert @user.dashboard_section_collapsed?("net_worth_chart")
|
|
assert_not @user.dashboard_section_collapsed?("outflows_donut")
|
|
|
|
# Second update: collapse outflows (should preserve net_worth)
|
|
@user.update_dashboard_preferences({
|
|
"collapsed_sections" => { "outflows_donut" => true }
|
|
})
|
|
@user.reload
|
|
|
|
assert @user.dashboard_section_collapsed?("net_worth_chart"),
|
|
"First collapsed section should still be collapsed"
|
|
assert @user.dashboard_section_collapsed?("outflows_donut"),
|
|
"Second collapsed section should be collapsed"
|
|
end
|
|
|
|
test "update_dashboard_preferences handles section_order updates" do
|
|
@user.update!(preferences: {})
|
|
|
|
# Set initial order
|
|
new_order = %w[outflows_donut net_worth_chart cashflow_sankey balance_sheet]
|
|
@user.update_dashboard_preferences({ "section_order" => new_order })
|
|
@user.reload
|
|
|
|
assert_equal new_order, @user.dashboard_section_order
|
|
end
|
|
|
|
test "handles empty preferences gracefully for dashboard methods" do
|
|
@user.update!(preferences: {})
|
|
|
|
# dashboard_section_collapsed? should return false when key is missing
|
|
assert_not @user.dashboard_section_collapsed?("net_worth_chart"),
|
|
"Should return false when collapsed_sections key is missing"
|
|
|
|
# dashboard_section_order should return default order when key is missing
|
|
assert_equal %w[cashflow_sankey outflows_donut net_worth_chart balance_sheet],
|
|
@user.dashboard_section_order,
|
|
"Should return default order when section_order key is missing"
|
|
|
|
# update_dashboard_preferences should work with empty preferences
|
|
@user.update_dashboard_preferences({ "section_order" => %w[balance_sheet] })
|
|
@user.reload
|
|
|
|
assert_equal %w[balance_sheet], @user.preferences["section_order"]
|
|
end
|
|
|
|
test "handles empty preferences gracefully for reports methods" do
|
|
@user.update!(preferences: {})
|
|
|
|
# reports_section_collapsed? should return false when key is missing
|
|
assert_not @user.reports_section_collapsed?("trends_insights"),
|
|
"Should return false when reports_collapsed_sections key is missing"
|
|
|
|
# reports_section_order should return default order when key is missing
|
|
assert_equal %w[trends_insights transactions_breakdown],
|
|
@user.reports_section_order,
|
|
"Should return default order when reports_section_order key is missing"
|
|
|
|
# update_reports_preferences should work with empty preferences
|
|
@user.update_reports_preferences({ "reports_section_order" => %w[transactions_breakdown] })
|
|
@user.reload
|
|
|
|
assert_equal %w[transactions_breakdown], @user.preferences["reports_section_order"]
|
|
end
|
|
|
|
test "handles missing nested keys in preferences for collapsed sections" do
|
|
@user.update!(preferences: { "section_order" => %w[cashflow] })
|
|
|
|
# Should return false when collapsed_sections key is missing entirely
|
|
assert_not @user.dashboard_section_collapsed?("net_worth_chart"),
|
|
"Should return false when collapsed_sections key is missing"
|
|
|
|
# Should return false when section_key is missing from collapsed_sections
|
|
@user.update!(preferences: { "collapsed_sections" => {} })
|
|
assert_not @user.dashboard_section_collapsed?("net_worth_chart"),
|
|
"Should return false when section key is missing from collapsed_sections"
|
|
end
|
|
|
|
# SSO-only user security tests
|
|
test "sso_only? returns true for user with OIDC identity and no password" do
|
|
sso_user = users(:sso_only)
|
|
assert_nil sso_user.password_digest
|
|
assert sso_user.oidc_identities.exists?
|
|
assert sso_user.sso_only?
|
|
end
|
|
|
|
test "sso_only? returns false for user with password and OIDC identity" do
|
|
# family_admin has both password and OIDC identity
|
|
assert @user.password_digest.present?
|
|
assert @user.oidc_identities.exists?
|
|
assert_not @user.sso_only?
|
|
end
|
|
|
|
test "sso_only? returns false for user with password but no OIDC identity" do
|
|
user_without_oidc = users(:empty)
|
|
assert user_without_oidc.password_digest.present?
|
|
assert_not user_without_oidc.oidc_identities.exists?
|
|
assert_not user_without_oidc.sso_only?
|
|
end
|
|
|
|
test "has_local_password? returns true when password_digest is present" do
|
|
assert @user.has_local_password?
|
|
end
|
|
|
|
test "has_local_password? returns false when password_digest is nil" do
|
|
sso_user = users(:sso_only)
|
|
assert_not sso_user.has_local_password?
|
|
end
|
|
|
|
test "user can be created without password when skip_password_validation is true" do
|
|
user = User.new(
|
|
email: "newssuser@example.com",
|
|
first_name: "New",
|
|
last_name: "SSO User",
|
|
skip_password_validation: true,
|
|
family: families(:empty)
|
|
)
|
|
assert user.valid?, user.errors.full_messages.to_sentence
|
|
assert user.save
|
|
assert_nil user.password_digest
|
|
end
|
|
|
|
test "user requires password on create when skip_password_validation is false" do
|
|
user = User.new(
|
|
email: "needspassword@example.com",
|
|
first_name: "Needs",
|
|
last_name: "Password",
|
|
family: families(:empty)
|
|
)
|
|
assert_not user.valid?
|
|
assert_includes user.errors[:password], "can't be blank"
|
|
end
|
|
|
|
# First user role assignment tests
|
|
test "role_for_new_family_creator returns super_admin when no users exist" do
|
|
# Delete all users to simulate fresh instance
|
|
User.destroy_all
|
|
|
|
assert_equal :super_admin, User.role_for_new_family_creator
|
|
end
|
|
|
|
test "role_for_new_family_creator returns fallback role when users exist" do
|
|
# Users exist from fixtures
|
|
assert User.exists?
|
|
|
|
assert_equal :admin, User.role_for_new_family_creator
|
|
assert_equal :member, User.role_for_new_family_creator(fallback_role: :member)
|
|
assert_equal "custom_role", User.role_for_new_family_creator(fallback_role: "custom_role")
|
|
end
|
|
|
|
# ActiveStorage attachment cleanup tests
|
|
test "purging a user removes attached profile image" do
|
|
user = users(:family_admin)
|
|
user.profile_image.attach(
|
|
io: StringIO.new("profile-image-data"),
|
|
filename: "profile.png",
|
|
content_type: "image/png"
|
|
)
|
|
|
|
attachment_id = user.profile_image.id
|
|
assert ActiveStorage::Attachment.exists?(attachment_id)
|
|
|
|
perform_enqueued_jobs do
|
|
user.purge
|
|
end
|
|
|
|
assert_not User.exists?(user.id)
|
|
assert_not ActiveStorage::Attachment.exists?(attachment_id)
|
|
end
|
|
|
|
test "purging the last user cascades to remove family and its export attachments" do
|
|
family = Family.create!(name: "Solo Family", locale: "en", date_format: "%m-%d-%Y", currency: "USD")
|
|
user = User.create!(family: family, email: "solo@example.com", password: "password123")
|
|
export = family.family_exports.create!
|
|
export.export_file.attach(
|
|
io: StringIO.new("export-data"),
|
|
filename: "export.zip",
|
|
content_type: "application/zip"
|
|
)
|
|
|
|
export_attachment_id = export.export_file.id
|
|
assert ActiveStorage::Attachment.exists?(export_attachment_id)
|
|
|
|
perform_enqueued_jobs do
|
|
user.purge
|
|
end
|
|
|
|
assert_not Family.exists?(family.id)
|
|
assert_not ActiveStorage::Attachment.exists?(export_attachment_id)
|
|
end
|
|
end
|