a7d6d88f6f
CI / changes (push) Has been cancelled
CI / cd libs/checkpoint (push) Has been cancelled
CI / cd libs/checkpoint-conformance (push) Has been cancelled
CI / cd libs/checkpoint-postgres (push) Has been cancelled
CI / cd libs/checkpoint-sqlite (push) Has been cancelled
CI / cd libs/cli (push) Has been cancelled
CI / cd libs/prebuilt (push) Has been cancelled
CI / cd libs/sdk-py (push) Has been cancelled
CI / cd libs/langgraph (push) Has been cancelled
CI / Check SDK methods matching (push) Has been cancelled
CI / Check CLI schema hasn't changed #3.13 (push) Has been cancelled
CI / CLI integration test (push) Has been cancelled
CI / sdk-py integration test (push) Has been cancelled
CI / CI Success (push) Has been cancelled
baseline / benchmark (push) Has been cancelled
Deploy Redirects to GitHub Pages / deploy (push) Has been cancelled
191 lines
7.6 KiB
Python
191 lines
7.6 KiB
Python
from typing import Any
|
|
|
|
import pytest
|
|
from langchain_core.runnables import RunnableConfig
|
|
from langgraph.checkpoint.base import (
|
|
Checkpoint,
|
|
CheckpointMetadata,
|
|
create_checkpoint,
|
|
empty_checkpoint,
|
|
)
|
|
|
|
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
|
|
|
|
|
class TestAsyncSqliteSaver:
|
|
@pytest.fixture(autouse=True)
|
|
def setup(self) -> None:
|
|
# objects for test setup
|
|
self.config_1: RunnableConfig = {
|
|
"configurable": {
|
|
"thread_id": "thread-1",
|
|
"checkpoint_id": "1",
|
|
"checkpoint_ns": "",
|
|
}
|
|
}
|
|
self.config_2: RunnableConfig = {
|
|
"configurable": {
|
|
"thread_id": "thread-2",
|
|
"checkpoint_id": "2",
|
|
"checkpoint_ns": "",
|
|
}
|
|
}
|
|
self.config_3: RunnableConfig = {
|
|
"configurable": {
|
|
"thread_id": "thread-2",
|
|
"checkpoint_id": "2-inner",
|
|
"checkpoint_ns": "inner",
|
|
}
|
|
}
|
|
|
|
self.chkpnt_1: Checkpoint = empty_checkpoint()
|
|
self.chkpnt_2: Checkpoint = create_checkpoint(self.chkpnt_1, {}, 1)
|
|
self.chkpnt_3: Checkpoint = empty_checkpoint()
|
|
|
|
self.metadata_1: CheckpointMetadata = {
|
|
"source": "input",
|
|
"step": 2,
|
|
"writes": {},
|
|
"score": 1,
|
|
}
|
|
self.metadata_2: CheckpointMetadata = {
|
|
"source": "loop",
|
|
"step": 1,
|
|
"writes": {"foo": "bar"},
|
|
"score": None,
|
|
}
|
|
self.metadata_3: CheckpointMetadata = {}
|
|
|
|
async def test_combined_metadata(self) -> None:
|
|
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
|
config: RunnableConfig = {
|
|
"configurable": {
|
|
"thread_id": "thread-2",
|
|
"checkpoint_ns": "",
|
|
"__super_private_key": "super_private_value",
|
|
},
|
|
"metadata": {"run_id": "my_run_id"},
|
|
}
|
|
await saver.aput(config, self.chkpnt_2, self.metadata_2, {})
|
|
checkpoint = await saver.aget_tuple(config)
|
|
assert checkpoint is not None and checkpoint.metadata == {
|
|
**self.metadata_2,
|
|
"run_id": "my_run_id",
|
|
}
|
|
|
|
async def test_asearch(self) -> None:
|
|
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
|
await saver.aput(self.config_1, self.chkpnt_1, self.metadata_1, {})
|
|
await saver.aput(self.config_2, self.chkpnt_2, self.metadata_2, {})
|
|
await saver.aput(self.config_3, self.chkpnt_3, self.metadata_3, {})
|
|
|
|
# call method / assertions
|
|
query_1 = {"source": "input"} # search by 1 key
|
|
query_2 = {
|
|
"step": 1,
|
|
"writes": {"foo": "bar"},
|
|
} # search by multiple keys
|
|
query_3: dict[str, Any] = {} # search by no keys, return all checkpoints
|
|
query_4 = {"source": "update", "step": 1} # no match
|
|
|
|
search_results_1 = [c async for c in saver.alist(None, filter=query_1)]
|
|
assert len(search_results_1) == 1
|
|
assert search_results_1[0].metadata == self.metadata_1
|
|
|
|
search_results_2 = [c async for c in saver.alist(None, filter=query_2)]
|
|
assert len(search_results_2) == 1
|
|
assert search_results_2[0].metadata == self.metadata_2
|
|
|
|
search_results_3 = [c async for c in saver.alist(None, filter=query_3)]
|
|
assert len(search_results_3) == 3
|
|
|
|
search_results_4 = [c async for c in saver.alist(None, filter=query_4)]
|
|
assert len(search_results_4) == 0
|
|
|
|
# search by config (defaults to checkpoints across all namespaces)
|
|
search_results_5 = [
|
|
c
|
|
async for c in saver.alist({"configurable": {"thread_id": "thread-2"}})
|
|
]
|
|
assert len(search_results_5) == 2
|
|
assert {
|
|
search_results_5[0].config["configurable"]["checkpoint_ns"],
|
|
search_results_5[1].config["configurable"]["checkpoint_ns"],
|
|
} == {"", "inner"}
|
|
|
|
# Test limit param
|
|
search_results_6 = [
|
|
c
|
|
async for c in saver.alist(
|
|
{"configurable": {"thread_id": "thread-2"}}, limit=1
|
|
)
|
|
]
|
|
assert len(search_results_6) == 1
|
|
assert search_results_6[0].config["configurable"]["thread_id"] == "thread-2"
|
|
|
|
# Test before param
|
|
search_results_7 = [
|
|
c async for c in saver.alist(None, before=search_results_5[1].config)
|
|
]
|
|
assert len(search_results_7) == 1
|
|
assert search_results_7[0].config["configurable"]["thread_id"] == "thread-1"
|
|
|
|
async def test_limit_parameter_sql_injection_prevention(self) -> None:
|
|
"""Test that the limit parameter properly uses parameterized queries to prevent SQL injection."""
|
|
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
|
# Setup: Create multiple checkpoints
|
|
for i in range(5):
|
|
config: RunnableConfig = {
|
|
"configurable": {
|
|
"thread_id": f"thread-{i}",
|
|
"checkpoint_ns": "",
|
|
}
|
|
}
|
|
checkpoint = empty_checkpoint()
|
|
metadata: CheckpointMetadata = {"index": i}
|
|
await saver.aput(config, checkpoint, metadata, {})
|
|
|
|
# Test that limit works correctly with valid integer
|
|
results = [c async for c in saver.alist(None, limit=2)]
|
|
assert len(results) == 2
|
|
|
|
# Test that limit=0 returns no results
|
|
results = [c async for c in saver.alist(None, limit=0)]
|
|
assert len(results) == 0
|
|
|
|
# Test that limit=None returns all results
|
|
results = [c async for c in saver.alist(None, limit=None)]
|
|
assert len(results) == 5
|
|
|
|
# Test explicit SQL injection attempt via limit parameter
|
|
# Even if type checking is bypassed and a malicious string is passed,
|
|
# the parameterized query will treat it as a value, not SQL code
|
|
# This would cause an error (can't convert string to int for LIMIT),
|
|
# which is the correct secure behavior
|
|
malicious_limits = [
|
|
"1; DROP TABLE checkpoints; --",
|
|
"1 OR 1=1",
|
|
"999999 UNION SELECT * FROM checkpoints",
|
|
]
|
|
|
|
for malicious_limit in malicious_limits:
|
|
# The parameterized query should safely reject non-integer limits
|
|
# or convert them in a way that prevents SQL injection
|
|
try:
|
|
# Bypass type checking by casting
|
|
results = [
|
|
c
|
|
async for c in saver.alist(None, limit=malicious_limit) # type: ignore
|
|
]
|
|
# If it doesn't raise an error, it should at least not execute the injection
|
|
# SQLite's parameter binding will try to convert the string to an integer
|
|
# which will either fail or treat it as 0
|
|
except Exception:
|
|
# Expected: SQLite should reject invalid limit values
|
|
pass
|
|
|
|
# Verify the checkpoints table still exists and has all data
|
|
# (would have been dropped if injection succeeded)
|
|
results = [c async for c in saver.alist(None, limit=None)]
|
|
assert len(results) == 5
|