mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-04 10:36:09 +00:00
e1ac5e0fc8
* Document multi-tenant workspace architecture * Add OSS and commercial workspace boundaries * docs: redesign multi-tenant workspace architecture * feat(tenancy): implement workspace isolation * docs(tenancy): record verification evidence * docs(tenancy): revise single-instance SaaS topology * docs(tenancy): refine architecture options * docs: finalize cloud v2 multi-tenant decisions * feat(tenancy): establish cloud isolation foundations * feat(tenancy): harden shared cloud runtime boundaries * docs(tenancy): record final isolation verification * fix(tenancy): close isolation and permission gaps * docs(tenancy): record final isolation verification * feat(tenancy): connect cloud workspace control plane * fix(build): install git for pinned SDK * docs(cloud): update control plane verification * chore: update multi-tenant SDK pin * fix(cloud): skip legacy model sync during startup * test(cloud): preserve minimal model manager fixtures * fix(cloud): preserve authenticated account context * fix(cloud): reuse authenticated account for user info * feat(cloud): complete Workspace settings navigation * test(web): cover Workspace dropdown menu * feat(web): place workspace controls in sidebar * refactor(web): streamline workspace controls * style(web): format workspace layout test * fix(cloud): surface runtime and workspace plan status * fix(plugin): keep runtime identity stable across restarts * fix(ui): widen and center workspace switcher * fix(ui): hide roles from workspace switcher * fix(ui): align workspace switcher with sidebar entries * feat(workspace): add in-product collaboration and direct Cloud launch * style: format collaboration changes * fix(workspace): bind collaboration APIs to tenant UoW * fix(cloud): preserve Core-owned collaboration state * test(cloud): require Space identity for invite registration * feat(cloud): complete secure invitation experience * style(web): format invitation flows * fix(cloud): recover box runtime without unscoped skill reload * feat(oss): enforce invitation account and owner billing flows * style: format OSS account service * test(oss): cover invitation logout handoff * fix(oss): resolve workspace owner in scoped session * feat(cloud): harden multi-tenant runtime resources * fix(cloud): bound runtime restart storms * fix(cloud): eliminate periodic runtime CPU spikes * fix(cloud): enforce instance capacity ceilings * fix(cloud): scope public login capability discovery * fix(cloud): bound tenant maintenance and monitoring work * fix(runtime): bound tenant resource amplification * fix(deps): pin green multi-tenant plugin SDK * fix(cloud): handle unavailable skill capability * fix(security): require authentication for image file endpoint (H-2) - Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY - Added Permission.RESOURCE_VIEW requirement - Prevents unauthenticated cross-tenant file access via leaked keys - Fixes HIGH severity finding from multi-tenant security review docs: add comprehensive database migration guide - Complete migration steps for OSS → multi-tenant - Backup, execution, verification procedures - Rollback scenarios and recovery plans - Performance tuning recommendations * test: add comprehensive cross-tenant isolation tests Added 7 critical test scenarios for multi-tenant boundaries: - Cross-tenant bot access prevention - Viewer role read-only enforcement - Removed member immediate access revocation - Model provider credential isolation - WebSocket message isolation - Invitation token workspace scoping - Multi-workspace context validation These tests address P0-2 coverage gaps for: - workspaces.py (membership & invitation flows) - user.py (authentication & authorization) - websocket_chat.py (real-time isolation) - plugins.py (resource access control) docs: finalize database migration guide * fix(security): resolve M-1, M-2, M-3 security findings M-1: WebSocket authorization TOCTOU race (FIXED) - Changed _revalidate_websocket_authorization to return RequestContext - Ensures validated context is used immediately without race window - Prevents removed members from sending messages during revalidation gap M-2: Model Manager cache workspace isolation (VERIFIED) - Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource) - Cache is properly scoped per workspace, no cross-tenant leakage possible - No code change needed, documented as working correctly M-3: Invitation lock workspace scoping (FIXED) - Changed lock key from token_digest to workspace_uuid:token_digest - Prevents DoS where attacker locks token in Workspace A to block Workspace B - Locks now isolated per workspace All MEDIUM severity findings from security review now resolved. * fix(cloud): unblock tenant CI and enforce knowledge quotas * fix(tenancy): scope rerank model sync --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
225 lines
6.6 KiB
Python
225 lines
6.6 KiB
Python
"""
|
|
Unit tests for log cache utilities.
|
|
|
|
Tests log page management and pointer-based retrieval.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
from langbot.pkg.utils.logcache import (
|
|
LogPage,
|
|
LogCache,
|
|
LOG_PAGE_SIZE,
|
|
MAX_CACHED_PAGES,
|
|
MAX_LOG_LINE_CHARS,
|
|
)
|
|
|
|
|
|
class TestLogPage:
|
|
"""Tests for LogPage class."""
|
|
|
|
def test_init_creates_empty_page(self):
|
|
"""LogPage initializes with empty logs list."""
|
|
page = LogPage(number=0)
|
|
|
|
assert page.number == 0
|
|
assert page.logs == []
|
|
|
|
def test_add_log_appends_to_list(self):
|
|
"""add_log appends log to the list."""
|
|
page = LogPage(number=0)
|
|
|
|
page.add_log('log entry 1')
|
|
page.add_log('log entry 2')
|
|
|
|
assert len(page.logs) == 2
|
|
assert page.logs[0] == 'log entry 1'
|
|
assert page.logs[1] == 'log entry 2'
|
|
|
|
def test_add_log_returns_false_when_not_full(self):
|
|
"""add_log returns False when page is not full."""
|
|
page = LogPage(number=0)
|
|
|
|
for i in range(LOG_PAGE_SIZE - 1):
|
|
result = page.add_log(f'log {i}')
|
|
assert result is False
|
|
|
|
def test_add_log_returns_true_when_full(self):
|
|
"""add_log returns True when page reaches LOG_PAGE_SIZE."""
|
|
page = LogPage(number=0)
|
|
|
|
for i in range(LOG_PAGE_SIZE - 1):
|
|
page.add_log(f'log {i}')
|
|
|
|
result = page.add_log('last log')
|
|
assert result is True
|
|
|
|
def test_add_log_exactly_page_size(self):
|
|
"""Page contains exactly LOG_PAGE_SIZE logs when full."""
|
|
page = LogPage(number=0)
|
|
|
|
for i in range(LOG_PAGE_SIZE):
|
|
page.add_log(f'log {i}')
|
|
|
|
assert len(page.logs) == LOG_PAGE_SIZE
|
|
|
|
|
|
class TestLogCache:
|
|
"""Tests for LogCache class."""
|
|
|
|
def test_init_creates_first_page(self):
|
|
"""LogCache initializes with first empty page."""
|
|
cache = LogCache()
|
|
|
|
assert len(cache.log_pages) == 1
|
|
assert cache.log_pages[0].number == 0
|
|
assert cache.log_pages[0].logs == []
|
|
|
|
def test_add_log_to_first_page(self):
|
|
"""add_log adds to the first page initially."""
|
|
cache = LogCache()
|
|
|
|
cache.add_log('test log')
|
|
|
|
assert len(cache.log_pages) == 1
|
|
assert cache.log_pages[0].logs[0] == 'test log'
|
|
|
|
def test_add_log_creates_new_page_when_full(self):
|
|
"""add_log creates new page when current page is full."""
|
|
cache = LogCache()
|
|
|
|
# Fill first page
|
|
for i in range(LOG_PAGE_SIZE):
|
|
cache.add_log(f'log {i}')
|
|
|
|
# Add one more to trigger new page
|
|
cache.add_log('overflow log')
|
|
|
|
assert len(cache.log_pages) == 2
|
|
assert cache.log_pages[1].number == 1
|
|
assert cache.log_pages[1].logs[0] == 'overflow log'
|
|
|
|
def test_add_log_removes_oldest_page_when_exceeds_max(self):
|
|
"""Cache removes oldest page when exceeding MAX_CACHED_PAGES."""
|
|
cache = LogCache()
|
|
|
|
# Fill enough pages to exceed MAX_CACHED_PAGES
|
|
total_logs = (MAX_CACHED_PAGES + 1) * LOG_PAGE_SIZE
|
|
for i in range(total_logs):
|
|
cache.add_log(f'log {i}')
|
|
|
|
# Should have exactly MAX_CACHED_PAGES pages
|
|
assert len(cache.log_pages) == MAX_CACHED_PAGES
|
|
|
|
# First page should not be page 0
|
|
assert cache.log_pages[0].number > 0
|
|
|
|
def test_get_log_by_pointer_single_page(self):
|
|
"""get_log_by_pointer retrieves logs from single page."""
|
|
cache = LogCache()
|
|
|
|
cache.add_log('log 1')
|
|
cache.add_log('log 2')
|
|
cache.add_log('log 3')
|
|
|
|
result, page_num, offset = cache.get_log_by_pointer(0, 0)
|
|
|
|
assert 'log 1' in result
|
|
assert 'log 2' in result
|
|
assert 'log 3' in result
|
|
|
|
def test_get_log_by_pointer_with_offset(self):
|
|
"""get_log_by_pointer respects start offset."""
|
|
cache = LogCache()
|
|
|
|
cache.add_log('log 1')
|
|
cache.add_log('log 2')
|
|
cache.add_log('log 3')
|
|
|
|
result, page_num, offset = cache.get_log_by_pointer(0, 1)
|
|
|
|
assert 'log 1' not in result
|
|
assert 'log 2' in result
|
|
assert 'log 3' in result
|
|
|
|
def test_get_log_by_pointer_across_pages(self):
|
|
"""get_log_by_pointer retrieves logs across pages."""
|
|
cache = LogCache()
|
|
|
|
# Fill first page and add to second
|
|
for i in range(LOG_PAGE_SIZE):
|
|
cache.add_log(f'page0 log {i}')
|
|
cache.add_log('page1 log 0')
|
|
|
|
# Get from first page offset 0
|
|
result, page_num, offset = cache.get_log_by_pointer(0, 0)
|
|
|
|
# Should contain all logs from page 0 and page 1
|
|
assert 'page0 log 0' in result
|
|
assert 'page1 log 0' in result
|
|
|
|
def test_get_log_by_pointer_from_second_page(self):
|
|
"""get_log_by_pointer can start from second page."""
|
|
cache = LogCache()
|
|
|
|
# Fill first page and add to second
|
|
for i in range(LOG_PAGE_SIZE):
|
|
cache.add_log(f'page0 log {i}')
|
|
cache.add_log('page1 log 0')
|
|
|
|
# Get from second page
|
|
result, page_num, offset = cache.get_log_by_pointer(1, 0)
|
|
|
|
assert 'page0' not in result
|
|
assert 'page1 log 0' in result
|
|
|
|
def test_page_numbers_sequential(self):
|
|
"""Page numbers are sequential."""
|
|
cache = LogCache()
|
|
|
|
# Create multiple pages
|
|
for i in range(LOG_PAGE_SIZE * 3):
|
|
cache.add_log(f'log {i}')
|
|
|
|
for i, page in enumerate(cache.log_pages):
|
|
assert page.number == i
|
|
|
|
def test_empty_cache_get_log(self):
|
|
"""get_log_by_pointer works with empty cache."""
|
|
cache = LogCache()
|
|
|
|
result, page_num, offset = cache.get_log_by_pointer(0, 0)
|
|
|
|
assert result == ''
|
|
|
|
def test_get_log_by_pointer_nonexistent_page(self):
|
|
"""get_log_by_pointer handles nonexistent page number."""
|
|
cache = LogCache()
|
|
|
|
cache.add_log('log 1')
|
|
|
|
# Request page that doesn't exist
|
|
result, page_num, offset = cache.get_log_by_pointer(99, 0)
|
|
|
|
# Returns empty or last available
|
|
# Behavior depends on implementation
|
|
|
|
def test_max_cached_pages_constant(self):
|
|
"""MAX_CACHED_PAGES is defined and reasonable."""
|
|
assert MAX_CACHED_PAGES > 0
|
|
assert MAX_CACHED_PAGES <= 100 # Reasonable upper bound
|
|
|
|
def test_log_page_size_constant(self):
|
|
"""LOG_PAGE_SIZE is defined and reasonable."""
|
|
assert LOG_PAGE_SIZE > 0
|
|
assert LOG_PAGE_SIZE <= 1000 # Reasonable upper bound
|
|
|
|
def test_single_log_line_is_bounded(self):
|
|
cache = LogCache()
|
|
|
|
cache.add_log('x' * (MAX_LOG_LINE_CHARS * 2))
|
|
|
|
assert len(cache.log_pages[0].logs[0]) == MAX_LOG_LINE_CHARS
|
|
assert cache.log_pages[0].logs[0].endswith('[log truncated]')
|