Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions .github/workflows/agent-tests.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Agent tests

on:
# no path filter on PRs, so both matrix jobs can be required checks
# no path filter on PRs, so each matrix job can be a required check
pull_request:
push:
branches: [main, feat/remote-indexing-agents]
Expand All @@ -16,16 +16,34 @@ on:

jobs:
test:
name: ${{ matrix.python == '3.13' && format('test ({0})', matrix.os) || format('test ({0}, Python {1})', matrix.os, matrix.python) }}
strategy:
fail-fast: false
max-parallel: 1
matrix:
os: [ubuntu-latest, windows-latest]
include:
- os: ubuntu-latest
python: '3.11'
- os: ubuntu-latest
python: '3.12'
- os: ubuntu-latest
python: '3.13'
- os: windows-latest
python: '3.13'
runs-on: ${{ matrix.os }}
env:
UV_PYTHON: ${{ matrix.python }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.13"
python-version: ${{ matrix.python }}
- uses: astral-sh/setup-uv@v7
- run: uv sync --all-packages --group dev
- run: uv run pytest agent/tests backend/tests/test_dockerfile.py -v
with:
enable-cache: true
cache-suffix: ${{ matrix.os }}-python-${{ matrix.python }}
cache-dependency-glob: |
uv.lock
**/pyproject.toml
- run: uv sync --frozen --all-packages --group dev
- run: uv run --frozen pytest agent/tests backend/tests/test_dockerfile.py -v
26 changes: 18 additions & 8 deletions .github/workflows/backend-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,15 @@ on:

jobs:
test:
name: ${{ matrix.python == '3.13' && 'test' || format('test (Python {0})', matrix.python) }}
strategy:
fail-fast: false
max-parallel: 1
matrix:
python: ['3.11', '3.12', '3.13']
runs-on: ubuntu-latest
env:
UV_PYTHON: ${{ matrix.python }}

services:
meilisearch:
Expand All @@ -40,7 +48,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
python-version: ${{ matrix.python }}

- name: Install uv
uses: astral-sh/setup-uv@v7
Expand All @@ -53,34 +61,36 @@ jobs:
path: |
.venv
~/.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('pyproject.toml', 'uv.lock', 'backend/pyproject.toml', 'cli/pyproject.toml') }}
key: ${{ runner.os }}-python-${{ matrix.python }}-uv-${{ hashFiles('pyproject.toml', 'uv.lock', 'agent/pyproject.toml', 'backend/pyproject.toml', 'cli/pyproject.toml', 'shared/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-uv-
${{ runner.os }}-python-${{ matrix.python }}-uv-

- name: Install workspace dependencies
run: |
uv sync --all-extras --all-groups
uv sync --frozen --all-extras --all-groups

- name: Run backend tests
env:
MEILI_URL: http://127.0.0.1:7700
MEILI_MASTER_KEY: test-master-key
run: |
uv run pytest backend/tests/ -v --tb=short --color=yes --cov=app
uv run --frozen pytest backend/tests/ -v --tb=short --color=yes --cov=app

- name: Run CLI tests
run: |
uv run pytest cli/tests/ -v --tb=short --color=yes
uv run --frozen pytest cli/tests/ -v --tb=short --color=yes

- name: Run shared tests
run: |
uv run pytest shared/tests/ -v --tb=short --color=yes
uv run --frozen pytest shared/tests/ -v --tb=short --color=yes

- name: Build onesearch-cli package
if: matrix.python == '3.13'
run: |
uv run --with build python -m build cli
uv run --frozen --with build python -m build cli

- name: Upload onesearch-cli artifacts
if: matrix.python == '3.13'
uses: actions/upload-artifact@v7
with:
name: onesearch-cli-dist
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Thanks for helping with OneSearch. Keep changes focused and easy to review. If a

## Prerequisites

- Python 3.10+ with [uv](https://docs.astral.sh/uv/)
- Python 3.11+ with [uv](https://docs.astral.sh/uv/)
- Node 22.13+ with npm
- Docker for a local Meilisearch container; Docker Compose for full-stack container checks

Expand Down
6 changes: 5 additions & 1 deletion agent/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@
name = "onesearch-agent"
version = "1.7.0"
description = "Secure remote indexing agent for OneSearch"
requires-python = ">=3.10"
requires-python = ">=3.11"
dependencies = [
"onesearch-shared",
"onesearch-backend",
# Protect standalone agent upgrades that retain an older installed backend.
"pillow>=13.0.0",
"pypdf>=6.21.2",
"certifi>=2026.10.19",
"click>=8",
"httpx>=0.25",
"pydantic>=2,<3",
Expand Down
28 changes: 28 additions & 0 deletions agent/tests/test_release_packaging.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import base64
import hashlib
import json
import tomllib
from pathlib import Path

import pytest
Expand All @@ -17,6 +18,7 @@
verify_key_pair,
)
from onesearch_agent.update import UpdateManager
from packaging.requirements import Requirement


def test_artifact_names_and_manifest_url_use_one_explicit_target_mapping():
Expand Down Expand Up @@ -97,3 +99,29 @@ def test_windows_frozen_entry_dispatches_only_no_cli_invocations_to_scm():
assert not should_launch_windows_service("win32", ["--help"])
assert not should_launch_windows_service("win32", ["unknown"])
assert not should_launch_windows_service("linux", [])


@pytest.mark.parametrize(
("package", "vulnerable_version", "patched_version"),
[
("pillow", "12.3.0", "13.0.0"),
("pypdf", "6.21.1", "6.21.2"),
("certifi", "2025.11.12", "2026.10.19"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Security & Privacy · Quick win

Test the later affected certifi release.

If the agent floor regresses to certifi>=2026.7.22, this test still passes: it excludes 2025.11.12 and includes 2026.10.19. The regressed constraint permits 2026.7.22, which is an affected release. Replace the excluded sample with 2026.7.22, or assert the required floor directly. (pypi.org)

],
)
def test_standalone_agent_metadata_rejects_vulnerable_parser_and_tls_versions(
package, vulnerable_version, patched_version
):
"""An already-installed older backend must not weaken an agent upgrade's floors."""
manifest = Path(__file__).resolve().parents[1] / "pyproject.toml"
metadata = tomllib.loads(manifest.read_text(encoding="utf-8"))
requirements = {}
for dependency in metadata["project"]["dependencies"]:
requirement = Requirement(dependency)
requirements[requirement.name.lower()] = requirement

assert package in requirements, f"{package} must be constrained by the agent itself"
requirement = requirements[package]
assert requirement.marker is None, "security floors must apply on every agent platform"
assert vulnerable_version not in requirement.specifier
assert patched_version in requirement.specifier
7 changes: 4 additions & 3 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "onesearch-backend"
version = "1.7.0"
description = "OneSearch backend API server"
license = {text = "AGPL-3.0-only"}
requires-python = ">=3.10"
requires-python = ">=3.11"
dependencies = [
"onesearch-shared",
"fastapi>=0.104.0,<0.200.0",
Expand All @@ -13,14 +13,15 @@ dependencies = [
"sqlalchemy>=2.0.36",
"alembic>=1.14.0",
"meilisearch>=0.31.0",
"pypdf>=6.15.0",
"certifi>=2026.10.19",
"pypdf>=6.21.2",
"python-frontmatter>=1.0.0",
"chardet>=5.2.0",
"python-multipart>=0.0.22",
"python-docx>=1.1.0",
"openpyxl>=3.1.0",
"python-pptx>=1.0.0",
"pillow>=12.3.0",
"pillow>=13.0.0",
"bcrypt>=4.0.0",
"PyJWT>=2.13.0",
"APScheduler>=3.10.0,<4.0.0",
Expand Down
42 changes: 37 additions & 5 deletions backend/tests/test_extractors.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from pathlib import Path
from tempfile import TemporaryDirectory
from pypdf import PdfWriter
from pypdf.generic import DecodedStreamObject, DictionaryObject, NameObject

from app.extractors import (
TextExtractor,
Expand Down Expand Up @@ -63,9 +64,21 @@ def sample_pdf_file(temp_dir):
"""Create sample PDF file"""
file_path = temp_dir / "sample.pdf"

# Create a simple PDF with PyPDF2
writer = PdfWriter()
writer.add_blank_page(width=200, height=200)
page = writer.add_blank_page(width=200, height=200)
page[NameObject("/Resources")] = DictionaryObject({
NameObject("/Font"): DictionaryObject({
NameObject("/F1"): DictionaryObject({
NameObject("/Type"): NameObject("/Font"),
NameObject("/Subtype"): NameObject("/Type1"),
NameObject("/BaseFont"): NameObject("/Helvetica"),
NameObject("/Encoding"): NameObject("/WinAnsiEncoding"),
}),
}),
})
content = DecodedStreamObject()
content.set_data(b"BT /F1 12 Tf 20 100 Td (Searchable PDF regression text.) Tj ET")
page.replace_contents(content)

# Add metadata
writer.add_metadata({
Expand Down Expand Up @@ -300,6 +313,11 @@ async def test_extract_pdf(self, sample_pdf_file):
assert doc.extension == "pdf"
assert doc.metadata["page_count"] == 1
assert doc.metadata.get("title") == "Test PDF Document"
assert "Searchable PDF regression text." in doc.content
assert doc.metadata["pages_extracted"] == 1
assert doc.metadata["extracted_text_length"] == len(doc.content)
assert doc.metadata["extraction_failed"] is False
assert "extraction_warning" not in doc.metadata

@pytest.mark.asyncio
async def test_supports_file(self, temp_dir):
Expand Down Expand Up @@ -407,7 +425,17 @@ def test_get_supported_extensions(self):

# Fixtures for Office files
@pytest.fixture
def sample_docx_file(temp_dir):
def sample_embedded_image(temp_dir):
"""Small real PNG used by the Office libraries when embedding images."""
from PIL import Image

image_path = temp_dir / "embedded.png"
Image.new("RGB", (32, 24), color="blue").save(image_path, "PNG")
return image_path


@pytest.fixture
def sample_docx_file(temp_dir, sample_embedded_image):
"""Create sample DOCX file"""
from docx import Document as DocxDocument

Expand All @@ -430,14 +458,16 @@ def sample_docx_file(temp_dir):
table.cell(1, 0).text = "Cell A2"
table.cell(1, 1).text = "Cell B2"

doc.add_picture(str(sample_embedded_image))
doc.save(file_path)
return str(file_path)


@pytest.fixture
def sample_xlsx_file(temp_dir):
def sample_xlsx_file(temp_dir, sample_embedded_image):
"""Create sample XLSX file"""
from openpyxl import Workbook
from openpyxl.drawing.image import Image as WorksheetImage

file_path = temp_dir / "sample.xlsx"
wb = Workbook()
Expand All @@ -457,12 +487,13 @@ def sample_xlsx_file(temp_dir):
ws2["A1"] = "Total Items"
ws2["B1"] = 2

ws1.add_image(WorksheetImage(str(sample_embedded_image)), "D1")
wb.save(file_path)
return str(file_path)


@pytest.fixture
def sample_pptx_file(temp_dir):
def sample_pptx_file(temp_dir, sample_embedded_image):
"""Create sample PPTX file"""
from pptx import Presentation
from pptx.util import Inches
Expand Down Expand Up @@ -494,6 +525,7 @@ def sample_pptx_file(temp_dir):
p = tf.add_paragraph()
p.text = "Second bullet point"

slide2.shapes.add_picture(str(sample_embedded_image), Inches(1), Inches(3), width=Inches(1))
prs.save(file_path)
return str(file_path)

Expand Down
45 changes: 28 additions & 17 deletions backend/tests/test_preview_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,27 +496,38 @@ def emit_bomb_warning(_path):
assert not image.converted


def test_generate_preview_handles_various_image_formats(tmp_path):
"""Test preview generation works with various image formats."""
@pytest.mark.parametrize(
("format_name", "mode", "color", "expected_rgb"),
[
("PNG", "RGB", (20, 160, 60), (20, 160, 60)),
("PNG", "RGBA", (255, 0, 0, 128), (255, 127, 127)),
("PNG", "LA", (80, 128), (167, 167, 167)),
("PNG", "P", 0, (0, 128, 0)),
("GIF", "P", 0, (0, 128, 0)),
],
)
def test_generate_preview_handles_various_image_formats(
tmp_path, format_name, mode, color, expected_rgb
):
"""Decode resized previews, including alpha composition and palette conversion."""
from app.services.preview_assets import generate_derived_jpeg_preview

formats = [
("PNG", "test.png"),
("GIF", "test.gif"),
]

for format_name, filename in formats:
# Create test image in format
test_image = Image.new("RGB", (200, 150), color="green")
image_path = tmp_path / filename
test_image.save(image_path, format_name)
image = Image.new(mode, (200, 150), color=color)
if mode == "P":
image.putpalette([0, 128, 0] + [0] * 765)
image_path = tmp_path / f"test-{mode}.{format_name.lower()}"
image.save(image_path, format_name)

# Generate preview
preview_bytes = generate_derived_jpeg_preview(image_path)
preview_bytes = generate_derived_jpeg_preview(image_path, max_dimension=100)

# Should always produce JPEG bytes
if preview_bytes is not None:
assert preview_bytes.startswith(b"\xff\xd8\xff"), f"Failed for {format_name}"
assert preview_bytes is not None, f"Preview failed for {format_name}/{mode}"
with Image.open(BytesIO(preview_bytes)) as preview:
preview.load()
assert preview.format == "JPEG"
assert preview.mode == "RGB"
assert preview.size == (100, 75)
assert preview.getpixel((50, 37)) == pytest.approx(expected_rgb, abs=8)
assert is_valid_derived_jpeg(preview_bytes)


@pytest.mark.parametrize("source_id", ["..", "../outside", "nested/../../outside"])
Expand Down
3 changes: 2 additions & 1 deletion cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ version = "1.7.0"
description = "Standalone CLI client for a running OneSearch server"
readme = "README.md"
license = {text = "AGPL-3.0-only"}
requires-python = ">=3.10"
requires-python = ">=3.11"
dependencies = [
"click>=8.1.0",
"requests>=2.31.0",
"certifi>=2026.10.19",
"rich>=13.0.0",
"python-dotenv>=1.0.0",
"pyyaml>=6.0.0",
Expand Down
Loading
Loading