Files
runyunzhouandqin-ctx d370ca6a7d feat(config): isolate account runtime resources (#5323)
* feat(config): isolate account runtime resources

* fix(runtime): harden account resource lifecycle

* test: streamline account runtime coverage

* refactor(config): remove redundant account runtime paths

* fix(config): make runtime publication loop-safe

* fix(config): order runtime change notifications

* test: align vector fixtures after rebase

* test: add account runtime isolation e2e coverage

* test: consolidate account runtime coverage into existing contracts

---------

Co-authored-by: qin-ctx <qinhaojie.exe@bytedance.com>
2026-09-24 15:08:56 +08:00

3339 lines
128 KiB
Python

# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
# SPDX-License-Identifier: AGPL-3.0
"""Tests for Admin API endpoints (openviking/server/routers/admin.py)."""
import asyncio
import hashlib
import json
import threading
import uuid
from copy import deepcopy
from unittest.mock import AsyncMock, Mock
import httpx
import pytest
import pytest_asyncio
import yaml
from fastapi import FastAPI
from fastapi import Request as FastAPIRequest
from fastapi.responses import JSONResponse
from openviking.config.binding import manager_over_source
from openviking.config.source import MemoryConfigSource
from openviking.pyagfs.exceptions import AGFSNotFoundError
from openviking.server.api_keys import APIKeyManager
from openviking.server.app import create_app
from openviking.server.config import ServerConfig, UserConfig
from openviking.server.dependencies import set_service
from openviking.server.identity import RequestContext, Role
from openviking.server.models import ERROR_CODE_TO_HTTP_STATUS, ErrorInfo, Response
from openviking.server.user_config import (
read_user_add_targets,
read_user_config,
user_config_backup_uri,
)
from openviking.service.core import OpenVikingService
from openviking.service.deletion import setup_deletion
from openviking.service.task_store import (
SYSTEM_TASK_ACCOUNT_ID,
SYSTEM_TASK_USER_ID,
)
from openviking.service.task_tracker import get_task_tracker
from openviking.session.memory.account_templates import (
EDITABLE_MEMORY_TEMPLATE_FIELDS,
account_memory_template_path,
resolve_account_memory_registry,
)
from openviking.session.memory.extract_loop import ExtractLoop
from openviking.session.memory.memory_isolation_handler import MemoryIsolationHandler
from openviking.session.memory.memory_type_registry import MemoryTypeRegistry, get_default_registry
from openviking.session.memory.patch_merge_context_provider import PatchMergeContextProvider
from openviking.session.memory.session_extract_context_provider import SessionExtractContextProvider
from openviking_cli.exceptions import OpenVikingError
from openviking_cli.session.user_id import UserIdentifier
from openviking_cli.utils.config import get_openviking_config
def _uid() -> str:
return f"acme_{uuid.uuid4().hex[:8]}"
def _seed_secret(user_id: str, seed: str) -> str:
return hashlib.sha256(f"{user_id}\0{seed}".encode("utf-8")).hexdigest()
ROOT_KEY = "admin-api-test-root-key-abcdef1234567890ab"
class _FakeAGFS:
def __init__(self):
self._files = {}
self._dirs = {"/", "/local"}
def read(self, path, **_kwargs):
if path not in self._files:
raise AGFSNotFoundError(path)
return self._files[path]
def write(self, path, content, **_kwargs):
self.ensure_parent_dirs(path)
self._files[path] = content
def mv(self, old_path, new_path, **_kwargs):
self._files[new_path] = self._files.pop(old_path)
def rm(self, path, **_kwargs):
self._files.pop(path, None)
def mkdir(self, path, **_kwargs):
self._dirs.add(path)
def ensure_parent_dirs(self, path, **_kwargs):
parent = path.rsplit("/", 1)[0]
if not parent:
return
current = ""
for part in [part for part in parent.strip("/").split("/") if part]:
current = f"{current}/{part}" if current else f"/{part}"
self._dirs.add(current)
def pathlock_acquire_exact(self, ctx, path, timeout_secs=0.0, owner_lease_ref=None):
return {"lease_ref": f"test:{path}"}
def pathlock_acquire_exact_batch(self, ctx, paths, timeout_secs=0.0, owner_lease_ref=None):
return self.pathlock_acquire_exact(ctx, paths[0], timeout_secs, owner_lease_ref)
def pathlock_release(self, ctx, lease):
return None
class _FakeVikingFS:
def __init__(self):
self.agfs = _FakeAGFS()
self.files = {}
self.writes = []
async def read_file(self, uri, **_kwargs):
if uri not in self.files:
raise FileNotFoundError(uri)
return self.files[uri]
async def _ensure_access(self, uri, ctx, *, action):
return None
async def write_file(self, uri, content, **_kwargs):
self.writes.append(uri)
self.files[uri] = content
async def rm(self, uri, **_kwargs):
self.files.pop(uri, None)
class _FakeService:
def __init__(self):
self.viking_fs = _FakeVikingFS()
self.sessions = self
self.runtime_config_manager = manager_over_source(MemoryConfigSource())
async def get_agent_evolution_enabled(self, account_id):
del account_id
return True
async def initialize_account_directories(self, ctx):
return None
async def initialize_user_directories(self, ctx):
return None
async def initialize_account_workspace(self, ctx):
return None
def _build_lightweight_admin_test_app() -> FastAPI:
from openviking.server.auth.plugins import ApiKeyAuthPlugin
from openviking.server.auth.registry import get_registry
from openviking.server.routers import admin as admin_router
app = FastAPI()
app.state.config = ServerConfig(root_api_key=ROOT_KEY)
fake_service = _FakeService()
app.state.fake_service = fake_service
set_service(fake_service)
@app.exception_handler(OpenVikingError)
async def openviking_error_handler(request: FastAPIRequest, exc: OpenVikingError):
http_status = ERROR_CODE_TO_HTTP_STATUS.get(exc.code, 500)
return JSONResponse(
status_code=http_status,
content=Response(
status="error",
error=ErrorInfo(code=exc.code, message=exc.message, details=exc.details),
).model_dump(),
)
manager = APIKeyManager(root_key=ROOT_KEY, viking_fs=fake_service.viking_fs)
app.state.api_key_manager = manager
# Set auth plugin (lifespan not triggered in ASGI tests)
registry = get_registry()
if registry.get("api_key") is None:
registry.register(ApiKeyAuthPlugin)
app.state.auth_plugin = registry.get("api_key")()
app.include_router(admin_router.router)
return app
@pytest_asyncio.fixture(scope="function")
async def lightweight_admin_app(monkeypatch):
app = _build_lightweight_admin_test_app()
await app.state.fake_service.runtime_config_manager.initialize()
await app.state.api_key_manager.load()
return app
@pytest_asyncio.fixture(scope="function")
async def lightweight_admin_client(lightweight_admin_app):
transport = httpx.ASGITransport(app=lightweight_admin_app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c:
yield c
@pytest_asyncio.fixture(scope="function")
async def admin_service(service):
"""Use the shared service fixture with local fake models."""
yield service
@pytest_asyncio.fixture(scope="function")
async def admin_app(admin_service):
from openviking.server.auth.plugins import ApiKeyAuthPlugin
from openviking.server.auth.registry import get_registry
config = ServerConfig(root_api_key=ROOT_KEY)
app = create_app(config=config, service=admin_service)
set_service(admin_service)
manager = APIKeyManager(root_key=ROOT_KEY, viking_fs=admin_service.viking_fs)
await manager.load()
app.state.api_key_manager = manager
app.state.deletion_service = await setup_deletion(
service=admin_service,
manager=manager,
)
# Set auth plugin (lifespan not triggered in ASGI tests)
registry = get_registry()
if registry.get("api_key") is None:
registry.register(ApiKeyAuthPlugin)
app.state.auth_plugin = registry.get("api_key")()
return app
@pytest_asyncio.fixture(scope="function")
async def admin_client(admin_app):
transport = httpx.ASGITransport(app=admin_app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c:
yield c
def root_headers():
return {"X-API-Key": ROOT_KEY}
def trusted_headers(
*,
account: str,
user: str,
include_api_key: bool = False,
):
headers = {
"X-OpenViking-Account": account,
"X-OpenViking-User": user,
}
if include_api_key:
headers["X-API-Key"] = ROOT_KEY
return headers
async def _agfs_exists(service: OpenVikingService, path: str) -> bool:
try:
await service.viking_fs._async_agfs.stat(path)
except Exception:
return False
return True
async def _agfs_mkdirp(service: OpenVikingService, path: str) -> None:
parts = [part for part in path.strip("/").split("/") if part]
current = ""
for part in parts:
current = f"{current}/{part}" if current else f"/{part}"
if await _agfs_exists(service, current):
continue
await service.viking_fs._async_agfs.mkdir(current)
async def _agfs_write(service: OpenVikingService, path: str, content: str) -> None:
await _agfs_mkdirp(service, path.rsplit("/", 1)[0])
await service.viking_fs._async_agfs.write(path, content.encode("utf-8"))
async def _agfs_read_text(service: OpenVikingService, path: str) -> str:
raw = await service.viking_fs._async_agfs.read(path)
if isinstance(raw, bytes):
return raw.decode("utf-8")
if hasattr(raw, "content"):
return raw.content.decode("utf-8")
return str(raw)
async def _wait_for_task(client: httpx.AsyncClient, task_id: str) -> dict:
# Budget ~30s: recursive AGFS cleanup can take a couple of seconds, and the
# original 1s budget was too tight on slower backends/interpreters.
for _ in range(600):
resp = await client.get(f"/api/v1/tasks/{task_id}", headers=root_headers())
assert resp.status_code == 200
task = resp.json()["result"]
if task["status"] in {"completed", "failed"}:
return task
await asyncio.sleep(0.05)
raise AssertionError(f"Task {task_id} did not finish")
# ---- Account CRUD ----
@pytest_asyncio.fixture
async def template_account(lightweight_admin_client):
account_id = _uid()
response = await lightweight_admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": account_id, "admin_user_id": "alice"},
headers=root_headers(),
)
assert response.status_code == 200, response.text
return account_id, {"X-API-Key": response.json()["result"]["user_key"]}
@pytest.mark.parametrize(
"memory_type",
["profile", "preferences", "entities", "events", "soul", "identity"],
)
async def test_account_memory_templates_publish_and_reset(
lightweight_admin_client,
lightweight_admin_app,
template_account,
memory_type,
):
client = lightweight_admin_client
account_id, headers = template_account
root = f"/api/v1/admin/accounts/{account_id}/memory-templates"
path = account_memory_template_path(account_id, memory_type)
fs = lightweight_admin_app.state.fake_service.viking_fs
settings_path = f"/local/{account_id}/_system/setting.json"
fs.agfs._files[settings_path] = b'{"agent_evolution":{"enabled":false}}'
registry = get_default_registry()
original = {s.memory_type: s.model_dump() for s in registry.list_all(True)}
initial = await client.get(root, headers=headers)
assert initial.status_code == 200, initial.text
assert {item["memory_type"] for item in initial.json()["result"]["templates"]} == {
"profile",
"events",
"preferences",
"entities",
"soul",
"identity",
}
assert all(item["status"] == "system_default" for item in initial.json()["result"]["templates"])
assert path not in fs.agfs._files
editable_fields = {
"profile": ("content",),
"events": ("event_name", "summary"),
"preferences": ("topic", "content"),
"entities": ("category", "name", "content"),
"soul": ("core_truths", "boundaries", "vibe", "continuity"),
"identity": ("creature", "name", "vibe", "avatar", "emoji", "introduction"),
}[memory_type]
body = {
"description": "Remember business facts in {{ language.lower() }}.",
"fields": [
{"name": name, "description": f"Business {name}: {{{{ language }}}}"}
for name in editable_fields
],
}
if memory_type in {"events", "soul", "identity"}:
variable = {"events": "summary", "soul": "core_truths", "identity": "introduction"}[
memory_type
]
body["content_template"] = "# Business\n{{ " + variable + " }}"
publish = await client.put(f"{root}/{memory_type}", json=body, headers=headers)
assert publish.status_code == 200, publish.text
result = publish.json()["result"]
assert result["status"] == "custom"
assert result["updated_at"]
assert all(result["effective"][key] == value for key, value in body.items() if key != "fields")
assert result["effective"]["fields"][0]["type"] == "string"
stored = yaml.safe_load(fs.agfs._files[path])
assert stored.pop("_updated_at") == result["updated_at"]
assert stored == result["effective"]
assert stored["memory_type"] == memory_type
assert "stage" in stored and "embedding_template" in stored
assert fs.agfs._files[settings_path] == b'{"agent_evolution":{"enabled":false}}'
assert (await client.get(f"{root}/{memory_type}", headers=headers)).json()["result"] == result
roundtrip = await client.put(f"{root}/{memory_type}", json=result["effective"], headers=headers)
assert roundtrip.status_code == 200, roundtrip.text
assert roundtrip.json()["result"] == result
listed = (await client.get(root, headers=headers)).json()["result"]["templates"]
assert (
next(item for item in listed if item["memory_type"] == memory_type)["effective"] == stored
)
resolved = await resolve_account_memory_registry(fs, account_id, registry)
assert resolved is not registry
assert all(resolved.get(schema.memory_type) is not schema for schema in registry.list_all(True))
schema = resolved.get(memory_type)
assert schema.description == body["description"]
expected = dict(original[memory_type])
expected["description"] = body["description"]
if "content_template" in body:
expected["content_template"] = body["content_template"]
expected["fields"] = [
{**field, "description": f"Business {field['name']}: {{{{ language }}}}"}
if field["name"] in editable_fields
else field
for field in expected["fields"]
]
assert schema.model_dump() == expected
# All fields, including Events goal/ranges, survive a partial request.
assert [field.name for field in schema.fields] == [
field["name"] for field in original[memory_type]["fields"]
]
assert {s.memory_type: s.model_dump() for s in registry.list_all(True)} == original
noop = await client.put(f"{root}/{memory_type}", json=body, headers=headers)
assert noop.json()["result"]["updated_at"] == result["updated_at"]
replacement = await client.put(
f"{root}/{memory_type}", json={"description": "Replacement"}, headers=headers
)
assert replacement.status_code == 200, replacement.text
assert replacement.json()["result"]["effective"]["fields"] == result["defaults"]["fields"]
assert yaml.safe_load(fs.agfs._files[path + ".backup"])["description"] == body["description"]
# An earlier extraction retains its complete snapshot across publication.
assert resolved.get(memory_type).description == body["description"]
reset = await client.delete(f"{root}/{memory_type}", headers=headers)
assert reset.status_code == 200, reset.text
assert reset.json()["result"]["effective"] == result["defaults"]
assert reset.json()["result"]["status"] == "system_default"
assert path not in fs.agfs._files
restored = await resolve_account_memory_registry(fs, account_id, registry)
assert {s.memory_type: s.model_dump() for s in restored.list_all(True)} == original
assert (await client.delete(f"{root}/{memory_type}", headers=headers)).status_code == 200
default_form = await client.put(
f"{root}/{memory_type}", json=result["defaults"], headers=headers
)
assert default_form.status_code == 200, default_form.text
assert default_form.json()["result"]["effective"] == result["defaults"]
assert default_form.json()["result"]["status"] == "system_default"
assert default_form.json()["result"]["updated_at"] is None
assert path not in fs.agfs._files
@pytest.mark.parametrize(
"memory_type,edit",
[
(kind, edit)
for kind in EDITABLE_MEMORY_TEMPLATE_FIELDS
for edit in ("exact", "description", "field_description")
]
+ [
(kind, edit)
for kind in ("events", "soul", "identity")
for edit in ("rstrip", "newline", "crlf", "heading")
],
)
async def test_account_memory_templates_default_form_roundtrip(
lightweight_admin_client,
lightweight_admin_app,
template_account,
memory_type,
edit,
):
from openviking.session.memory.dataclass import MemoryFile
from openviking.session.memory.memory_updater import ExtractContext
from openviking.session.memory.utils.memory_file_utils import MemoryFileUtils
from openviking.session.memory.utils.template_utils import TemplateUtils
account_id, headers = template_account
client = lightweight_admin_client
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/{memory_type}"
initial = await client.get(url, headers=headers)
assert initial.status_code == 200, initial.text
assert initial.json()["result"]["status"] == "system_default"
defaults = initial.json()["result"]["defaults"]
# Match the managed form: only editable fields from the actual GET result.
body = {
"description": defaults["description"],
"fields": [
{"name": f["name"], "description": f["description"]}
for f in defaults["fields"]
if f["name"] in EDITABLE_MEMORY_TEMPLATE_FIELDS[memory_type]
],
}
if memory_type in ("events", "soul", "identity"):
original = defaults["content_template"]
body["content_template"] = {
"rstrip": original.rstrip(),
"newline": original + "\n",
"crlf": original.replace("\n", "\r\n"),
"heading": "# Account memory\n" + original,
}.get(edit, original)
if edit == "description":
body["description"] += "\nAccount instructions."
elif edit == "field_description":
body["fields"][0]["description"] += "\nAccount field instructions."
published = await client.put(url, json=body, headers=headers)
assert published.status_code == 200, published.text
result = published.json()["result"]
assert result["defaults"] == defaults
effective = result["effective"]
is_default = effective == defaults
assert result["status"] == ("system_default" if is_default else "custom")
assert (result["updated_at"] is None) == is_default
assert effective["description"] == body["description"]
for field in body["fields"]:
actual = next(f for f in effective["fields"] if f["name"] == field["name"])
assert actual["description"] == field["description"]
assert (await client.get(url, headers=headers)).json()["result"] == result
roundtrip = await client.put(url, json=effective, headers=headers)
assert roundtrip.status_code == 200, roundtrip.text
assert roundtrip.json()["result"] == result
fs = lightweight_admin_app.state.fake_service.viking_fs
snapshot = await resolve_account_memory_registry(fs, account_id, get_default_registry())
schema = snapshot.get(memory_type)
if "content_template" in body:
assert schema.content_template == effective["content_template"] == body["content_template"]
assert schema._account_content_template == (body["content_template"] != original)
values = {f.name: "Business fact" for f in schema.fields}
values["ranges"] = ""
context = ExtractContext([])
rendered = MemoryFileUtils.write(
MemoryFile(memory_type=memory_type, extra_fields=values),
content_template=schema.content_template,
extract_context=context,
account_content_template_type=memory_type if schema._account_content_template else None,
)
assert MemoryFileUtils.read(rendered).content == TemplateUtils.render(
body["content_template"], values, context
)
reset = await client.delete(url, headers=headers)
assert reset.status_code == 200, reset.text
assert reset.json()["result"]["effective"] == defaults
assert reset.json()["result"]["status"] == "system_default"
def _editable_template_form(defaults, memory_type):
body = {
"description": defaults["description"],
"fields": [
{"name": field["name"], "description": field["description"]}
for field in defaults["fields"]
if field["name"] in EDITABLE_MEMORY_TEMPLATE_FIELDS[memory_type]
],
}
if memory_type in ("events", "soul", "identity"):
body["content_template"] = defaults["content_template"]
return body
@pytest.mark.parametrize(
"memory_type,module",
[
(kind, module)
for kind, fields in EDITABLE_MEMORY_TEMPLATE_FIELDS.items()
for module in ("description", *fields)
]
+ [(kind, "content_template") for kind in ("events", "soul", "identity")],
)
async def test_account_memory_templates_module_reset_clears_override(
lightweight_admin_client, lightweight_admin_app, template_account, memory_type, module
):
account_id, headers = template_account
client = lightweight_admin_client
root = f"/api/v1/admin/accounts/{account_id}/memory-templates"
url = f"{root}/{memory_type}"
initial = (await client.get(url, headers=headers)).json()["result"]
form = _editable_template_form(initial["defaults"], memory_type)
body = deepcopy(form)
if module in ("description", "content_template"):
body[module] += "\nAccount instructions."
else:
field = next(field for field in body["fields"] if field["name"] == module)
field["description"] += "\nAccount field instructions."
publish = await client.put(url, json=body, headers=headers)
assert publish.status_code == 200, publish.text
result = publish.json()["result"]
assert result["status"] == "custom"
fs = lightweight_admin_app.state.fake_service.viking_fs
path = account_memory_template_path(account_id, memory_type)
previous = fs.agfs._files[path]
snapshot = await resolve_account_memory_registry(fs, account_id, get_default_registry())
previous_schema = snapshot.get(memory_type).model_dump()
untouched = {key: value for key, value in fs.agfs._files.items() if key != path}
# Reopen the editor, reset only the edited module, then save the whole form.
reopened = (await client.get(url, headers=headers)).json()["result"]
body = _editable_template_form(reopened["effective"], memory_type)
if module in ("description", "content_template"):
body[module] = form[module]
else:
field = next(field for field in body["fields"] if field["name"] == module)
field["description"] = next(
field["description"] for field in form["fields"] if field["name"] == module
)
# Field order does not change the completed configuration.
body["fields"].reverse()
restored = await client.put(url, json=body, headers=headers)
assert restored.status_code == 200, restored.text
assert restored.json()["result"] == initial
assert path not in fs.agfs._files
assert fs.agfs._files[path + ".backup"] == previous
assert all(fs.agfs._files[key] == value for key, value in untouched.items())
assert (await client.get(url, headers=headers)).json()["result"] == initial
listed = (await client.get(root, headers=headers)).json()["result"]["templates"]
assert next(item for item in listed if item["memory_type"] == memory_type) == {
key: value for key, value in initial.items() if key != "account_id"
}
resolved = await resolve_account_memory_registry(fs, account_id, get_default_registry())
assert (
resolved.get(memory_type).model_dump()
== get_default_registry().get(memory_type).model_dump()
)
assert snapshot.get(memory_type).model_dump() == previous_schema
stored = dict(fs.agfs._files)
repeated = await client.put(url, json=body, headers=headers)
assert repeated.status_code == 200, repeated.text
assert repeated.json()["result"] == initial
assert fs.agfs._files == stored
@pytest.mark.parametrize("memory_type", EDITABLE_MEMORY_TEMPLATE_FIELDS)
async def test_account_memory_templates_module_reset_keeps_other_edits(
lightweight_admin_client, lightweight_admin_app, template_account, memory_type
):
account_id, headers = template_account
client = lightweight_admin_client
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/{memory_type}"
initial = (await client.get(url, headers=headers)).json()["result"]
body = _editable_template_form(initial["defaults"], memory_type)
body["description"] += "\nType edit."
body["fields"][0]["description"] += "\nField edit."
response = await client.put(url, json=body, headers=headers)
assert response.status_code == 200, response.text
body["description"] = initial["defaults"]["description"]
response = await client.put(url, json=body, headers=headers)
assert response.status_code == 200, response.text
result = response.json()["result"]
assert result["status"] == "custom"
assert result["updated_at"]
assert result["effective"]["description"] == initial["defaults"]["description"]
field = next(f for f in result["effective"]["fields"] if f["name"] == body["fields"][0]["name"])
assert field["description"] == body["fields"][0]["description"]
assert account_memory_template_path(account_id, memory_type) in (
lightweight_admin_app.state.fake_service.viking_fs.agfs._files
)
async def test_account_memory_templates_save_clears_legacy_default_override(
lightweight_admin_client, lightweight_admin_app, template_account
):
account_id, headers = template_account
client = lightweight_admin_client
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/profile"
initial = (await client.get(url, headers=headers)).json()["result"]
legacy = {**initial["defaults"], "_updated_at": "2026-09-01T00:00:00+00:00"}
fs = lightweight_admin_app.state.fake_service.viking_fs
path = account_memory_template_path(account_id, "profile")
fs.agfs._files[path] = yaml.safe_dump(legacy).encode()
before = (await client.get(url, headers=headers)).json()["result"]
assert before["status"] == "custom" # Reads do not migrate persisted overrides.
response = await client.put(url, json=before["effective"], headers=headers)
assert response.status_code == 200, response.text
assert response.json()["result"] == initial
assert path not in fs.agfs._files
@pytest.mark.parametrize(
"body",
[
{"description": 123},
{"description": "{{ language | length }}"},
{"description": "{{ cycler.__init__.__globals__.__builtins__.len('harmless') }}"},
{"description": "literal {{ unclosed"},
{"fields": [{"name": "summary", "description": "{{ summary }}"}]},
{"fields": [{"name": "summary", "description": "{{ language | trim('x') }}"}]},
{"fields": [{"name": "summary", "description": "literal {{ unclosed"}]},
{"_account_description": False},
{"fields": [{"name": "summary", "_account_description": False}]},
{"memory_type": "other"},
{"fields": None},
{"fields": [{}]},
{"fields": [{"name": "x", "type": "not-a-type"}]},
{"fields": [{"name": "x", "merge_op": "not-an-op"}]},
{"fields": [{"name": "x"}, {"name": "x"}]},
{"fields": [{"name": "x", "description": "{{"}]},
{"content_template": "{{"},
{"content_template": "{{ unknown_field }}"},
{"content_template": "{{ extract_context.messages }}"},
{"content_template": "{% include 'private.yaml' %}"},
{"content_template": "{{ summary | attr('__class__') }}"},
{"content_template": "{{ summary | length }}"},
{"content_template": "{{ summary | trim('x') }}"},
{"content_template": "{{ summary | default('pending', true) }}"},
{"content_template": "{{ summary.strip('x') }}"},
{"content_template": "x" * (64 * 1024 + 1)},
],
)
async def test_account_memory_templates_reject_invalid_configuration(
lightweight_admin_client,
lightweight_admin_app,
template_account,
body,
):
account_id, headers = template_account
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/events"
assert (
await lightweight_admin_client.put(
url, json={"description": "Keep current"}, headers=headers
)
).status_code == 200
fs = lightweight_admin_app.state.fake_service.viking_fs
original = dict(fs.agfs._files)
response = await lightweight_admin_client.put(url, json=body, headers=headers)
assert response.status_code in {400, 422}, response.text
assert fs.agfs._files == original
@pytest.mark.parametrize("field", ["description", "fields.content.description"])
@pytest.mark.parametrize(
("text", "reason"),
[
("{{ language | length }}", "unsupported_filter"),
("{{ cycler.__init__.__globals__.__builtins__.len('harmless') }}", "unsupported_call"),
("{{", "invalid_jinja"),
],
)
async def test_account_memory_templates_description_diagnostics_and_legacy_recovery(
lightweight_admin_client, lightweight_admin_app, template_account, field, text, reason
):
account_id, headers = template_account
client = lightweight_admin_client
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/profile"
fs = lightweight_admin_app.state.fake_service.viking_fs
active = await client.put(url, json={"description": "Current"}, headers=headers)
assert active.status_code == 200
before = dict(fs.agfs._files)
body = (
{"description": text}
if field == "description"
else {"fields": [{"name": "content", "description": text}]}
)
response = await client.put(url, json=body, headers=headers)
assert response.status_code == 400, response.text
details = response.json()["error"]["details"]
assert details["field"] == field
assert details["reason"] == reason
assert details["line"] == 1
assert fs.agfs._files == before
# An old well-formed YAML override may contain now-unsupported expressions.
# Reading/resetting must stay possible, while extraction must reject it.
path = account_memory_template_path(account_id, "profile")
stored = yaml.safe_load(fs.agfs._files[path])
target = stored if field == "description" else stored["fields"][0]
target["description"] = text
target["_account_description"] = False
fs.agfs._files[path] = yaml.safe_dump(stored).encode()
assert (await client.get(url, headers=headers)).status_code == 200
from openviking_cli.exceptions import FailedPreconditionError
with pytest.raises(FailedPreconditionError, match="republish or reset"):
await resolve_account_memory_registry(fs, account_id, MemoryTypeRegistry())
assert (await client.delete(url, headers=headers)).status_code == 200
assert path not in fs.agfs._files
@pytest.mark.parametrize("memory_type", list(EDITABLE_MEMORY_TEMPLATE_FIELDS))
@pytest.mark.parametrize("character", ["a", "中", "😀"])
async def test_account_memory_templates_description_character_limit(
lightweight_admin_client,
lightweight_admin_app,
template_account,
memory_type,
character,
):
account_id, headers = template_account
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/{memory_type}"
text = character * 50_000
fs = lightweight_admin_app.state.fake_service.viking_fs
# Check one field at a time without exceeding the aggregate 1 MiB cap.
for field_name in [None, *EDITABLE_MEMORY_TEMPLATE_FIELDS[memory_type]]:
body = {"description": text}
target = body
if field_name is not None:
target = {"name": field_name, "description": text}
body["fields"] = [target]
response = await lightweight_admin_client.put(url, json=body, headers=headers)
assert response.status_code == 200, response.text
effective = response.json()["result"]["effective"]
assert effective["description"] == text
if field_name is not None:
field = next(field for field in effective["fields"] if field["name"] == field_name)
assert field["description"] == text
original = dict(fs.agfs._files)
target["description"] = text + character
response = await lightweight_admin_client.put(url, json=body, headers=headers)
assert response.status_code == 400, response.text
assert "50000 Unicode characters" in response.json()["error"]["message"]
assert fs.agfs._files == original
assert (await lightweight_admin_client.get(url, headers=headers)).status_code == 200
assert (await lightweight_admin_client.delete(url, headers=headers)).status_code == 200
async def test_account_memory_templates_content_validation_error_details(
lightweight_admin_client,
template_account,
):
account_id, headers = template_account
response = await lightweight_admin_client.put(
f"/api/v1/admin/accounts/{account_id}/memory-templates/events",
json={"content_template": "# Summary\n{{ typo }}"},
headers=headers,
)
assert response.status_code == 400
assert response.json()["error"]["details"] == {
"field": "content_template",
"reason": "unknown_variable",
"line": 2,
}
@pytest.mark.parametrize(
"expression",
["selected", "ranges|default('')|trim", "ranges|default('0-999')", "ranges if summary else ''"],
)
async def test_account_memory_templates_range_expressions_reach_file_body(
lightweight_admin_client, lightweight_admin_app, template_account, expression
):
from openviking.message import Message, TextPart
from openviking.session.memory.dataclass import MemoryFile
from openviking.session.memory.memory_updater import ExtractContext
from openviking.session.memory.utils.memory_file_utils import MemoryFileUtils
account_id, headers = template_account
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/events"
template = (
"{% set selected = ranges %}{{ extract_context.get_event_content("
+ expression
+ ", summary, 0) }}"
)
published = await lightweight_admin_client.put(
url, json={"content_template": template}, headers=headers
)
assert published.status_code == 200, published.text
result = (await lightweight_admin_client.get(url, headers=headers)).json()["result"]
assert result["effective"]["content_template"] == template
roundtrip = await lightweight_admin_client.put(url, json=result["effective"], headers=headers)
assert roundtrip.status_code == 200, roundtrip.text
fs = lightweight_admin_app.state.fake_service.viking_fs
snapshot = await resolve_account_memory_registry(fs, account_id, MemoryTypeRegistry())
schema = snapshot.get("events")
context = ExtractContext(
[
Message(id="m1", role="user", parts=[TextPart("Selected source message")]),
Message(id="m2", role="user", parts=[TextPart("Unrelated source message")]),
]
)
rendered = MemoryFileUtils.write(
MemoryFile(memory_type="events", extra_fields={"ranges": "0", "summary": "summary"}),
content_template=schema.content_template,
extract_context=context,
account_content_template_type="events" if schema._account_content_template else None,
)
content = MemoryFileUtils.read(rendered).content
assert "Selected source message" in content
assert "Unrelated source message" not in content
async def test_account_memory_templates_range_values_are_checked_at_render_time(
lightweight_admin_client, lightweight_admin_app, template_account
):
from types import SimpleNamespace
from openviking.session.memory.dataclass import MemoryFile
from openviking.session.memory.utils.content_template import ContentTemplateError
from openviking.session.memory.utils.memory_file_utils import MemoryFileUtils
account_id, headers = template_account
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/events"
template = "{% set selected = summary %}{{ extract_context.get_year(selected) }}"
published = await lightweight_admin_client.put(
url, json={"content_template": template}, headers=headers
)
assert published.status_code == 200, published.text
fs = lightweight_admin_app.state.fake_service.viking_fs
snapshot = await resolve_account_memory_registry(fs, account_id, MemoryTypeRegistry())
schema = snapshot.get("events")
get_year = Mock(spec=[], return_value="2026")
with pytest.raises(ContentTemplateError, match="invalid_ranges"):
MemoryFileUtils.write(
MemoryFile(memory_type="events", extra_fields={"ranges": "0", "summary": "0-999"}),
content_template=schema.content_template,
extract_context=SimpleNamespace(get_year=get_year),
account_content_template_type="events",
)
get_year.assert_not_called()
@pytest.mark.parametrize(
"memory_type, field_name",
[("events", "summary"), ("soul", "core_truths"), ("identity", "introduction")],
)
@pytest.mark.parametrize("formatting", [".strip().upper()", " | trim | upper"])
async def test_account_memory_templates_string_formatting_reaches_file_body(
lightweight_admin_client,
lightweight_admin_app,
template_account,
memory_type,
field_name,
formatting,
):
from openviking.session.memory.dataclass import MemoryFile
from openviking.session.memory.utils.memory_file_utils import MemoryFileUtils
account_id, headers = template_account
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/{memory_type}"
body = {"content_template": "# {{ " + field_name + formatting + " }}"}
response = await lightweight_admin_client.put(url, json=body, headers=headers)
assert response.status_code == 200, response.text
effective = (await lightweight_admin_client.get(url, headers=headers)).json()["result"][
"effective"
]
assert effective["content_template"] == body["content_template"]
roundtrip = await lightweight_admin_client.put(url, json=effective, headers=headers)
assert roundtrip.status_code == 200, roundtrip.text
fs = lightweight_admin_app.state.fake_service.viking_fs
snapshot = await resolve_account_memory_registry(fs, account_id, MemoryTypeRegistry())
schema = snapshot.get(memory_type)
assert schema._account_content_template is True
rendered = MemoryFileUtils.write(
MemoryFile(memory_type=memory_type, extra_fields={field_name: " business fact "}),
content_template=schema.content_template,
account_content_template_type=schema.memory_type,
)
assert MemoryFileUtils.read(rendered).content == "# BUSINESS FACT"
async def test_account_memory_templates_description_only_keeps_safe_deployment_methods(
lightweight_admin_client,
lightweight_admin_app,
template_account,
monkeypatch,
):
account_id, headers = template_account
defaults = MemoryTypeRegistry()
defaults.get("events").content_template = "# {{ summary.strip().upper() }}"
monkeypatch.setattr("openviking.server.routers.admin.get_default_registry", lambda: defaults)
response = await lightweight_admin_client.put(
f"/api/v1/admin/accounts/{account_id}/memory-templates/events",
json={"description": "Capture release decisions"},
headers=headers,
)
assert response.status_code == 200, response.text
assert response.json()["result"]["effective"]["content_template"] == (
"# {{ summary.strip().upper() }}"
)
snapshot = await resolve_account_memory_registry(
lightweight_admin_app.state.fake_service.viking_fs, account_id, defaults
)
assert snapshot.get("events")._account_content_template is False
assert snapshot.get("events").content_template == "# {{ summary.strip().upper() }}"
@pytest.mark.parametrize(
"memory_type, field_name",
[("events", "summary"), ("soul", "core_truths"), ("identity", "introduction")],
)
@pytest.mark.parametrize("request_kind", ["description_only", "empty", "roundtrip"])
async def test_account_memory_templates_inherit_exact_deployment_body(
lightweight_admin_client,
lightweight_admin_app,
template_account,
monkeypatch,
memory_type,
field_name,
request_kind,
):
from openviking.session.memory.dataclass import MemoryFile
from openviking.session.memory.utils.memory_file_utils import MemoryFileUtils
account_id, headers = template_account
defaults = MemoryTypeRegistry()
# Deployment code may use methods/filters that custom Account bodies cannot.
deployment_body = "# {{ " + field_name + ".replace('old', 'new') | upper }}"
defaults.get(memory_type).content_template = deployment_body
monkeypatch.setattr("openviking.server.routers.admin.get_default_registry", lambda: defaults)
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/{memory_type}"
body = {"description": "Account instructions"} if request_kind == "description_only" else {}
if request_kind == "roundtrip":
response = await lightweight_admin_client.get(url, headers=headers)
assert response.status_code == 200, response.text
body = response.json()["result"]["effective"]
body["description"] = "Account instructions"
response = await lightweight_admin_client.put(url, json=body, headers=headers)
assert response.status_code == 200, response.text
assert response.json()["result"]["status"] == (
"system_default" if request_kind == "empty" else "custom"
)
assert response.json()["result"]["effective"]["content_template"] == deployment_body
# Roundtrip a persisted override too, not only defaults returned before a PUT.
response = await lightweight_admin_client.put(
url, json=response.json()["result"]["effective"], headers=headers
)
assert response.status_code == 200, response.text
fs = lightweight_admin_app.state.fake_service.viking_fs
path = account_memory_template_path(account_id, memory_type)
if request_kind == "empty":
assert path not in fs.agfs._files
assert response.json()["result"]["updated_at"] is None
else:
stored = yaml.safe_load(fs.agfs._files[path])
assert "_account_content_template" not in stored
snapshot = await resolve_account_memory_registry(fs, account_id, defaults)
schema = snapshot.get(memory_type)
assert schema._account_content_template is False
assert schema.content_template == deployment_body
assert defaults.get(memory_type)._account_content_template is False
rendered = MemoryFileUtils.write(
MemoryFile(memory_type=memory_type, extra_fields={field_name: "old fact"}),
content_template=schema.content_template,
account_content_template_type=(
schema.memory_type if schema._account_content_template else None
),
)
assert MemoryFileUtils.read(rendered).content == "# NEW FACT"
@pytest.mark.parametrize("alteration", ["whitespace", "different_body"])
async def test_account_memory_templates_recheck_persisted_body_without_trusting_flags(
lightweight_admin_client,
lightweight_admin_app,
template_account,
monkeypatch,
alteration,
):
from openviking_cli.exceptions import FailedPreconditionError
account_id, headers = template_account
defaults = MemoryTypeRegistry()
deployment_body = "{{ summary | length }}"
defaults.get("events").content_template = deployment_body
monkeypatch.setattr("openviking.server.routers.admin.get_default_registry", lambda: defaults)
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/events"
response = await lightweight_admin_client.put(
url, json={"description": "Account instructions"}, headers=headers
)
assert response.status_code == 200, response.text
fs = lightweight_admin_app.state.fake_service.viking_fs
path = account_memory_template_path(account_id, "events")
original = fs.agfs._files[path]
altered_body = deployment_body + (" " if alteration == "whitespace" else "changed")
# Even a whitespace change is custom; equality is exact, not normalized.
response = await lightweight_admin_client.put(
url, json={"content_template": altered_body}, headers=headers
)
assert response.status_code == 400, response.text
assert fs.agfs._files[path] == original
corrupted = yaml.safe_load(original)
corrupted["content_template"] = altered_body
corrupted["_account_content_template"] = False
corrupted["deployment_content_template"] = altered_body
fs.agfs._files[path] = yaml.safe_dump(corrupted).encode()
with pytest.raises(FailedPreconditionError):
await resolve_account_memory_registry(fs, account_id, defaults)
# Management reads remain available for recovery; no unsafe body is rendered.
assert (await lightweight_admin_client.get(url, headers=headers)).status_code == 200
@pytest.mark.parametrize("restore_defaults", [False, True])
async def test_account_memory_templates_recheck_trust_after_deployment_change(
lightweight_admin_client,
lightweight_admin_app,
template_account,
monkeypatch,
restore_defaults,
):
from openviking_cli.exceptions import FailedPreconditionError
account_id, headers = template_account
defaults = MemoryTypeRegistry()
old_body = "{{ summary | length }}"
defaults.get("events").content_template = old_body
monkeypatch.setattr("openviking.server.routers.admin.get_default_registry", lambda: defaults)
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/events"
response = await lightweight_admin_client.put(
url, json={"description": "Account instructions"}, headers=headers
)
assert response.status_code == 200, response.text
fs = lightweight_admin_app.state.fake_service.viking_fs
old_snapshot = await resolve_account_memory_registry(fs, account_id, defaults)
if restore_defaults:
response = await lightweight_admin_client.put(url, json={}, headers=headers)
assert response.status_code == 200, response.text
assert response.json()["result"]["status"] == "system_default"
defaults.get("events").content_template = "# {{ summary }}"
if restore_defaults:
current = await resolve_account_memory_registry(fs, account_id, defaults)
assert current.get("events").content_template == "# {{ summary }}"
else:
with pytest.raises(FailedPreconditionError):
await resolve_account_memory_registry(fs, account_id, defaults)
# Already-started extractions keep their snapshot, not a mutable defaults view.
assert old_snapshot.get("events").content_template == old_body
assert old_snapshot.get("events")._account_content_template is False
response = await lightweight_admin_client.put(
url, json={"description": "Use current defaults"}, headers=headers
)
assert response.status_code == 200, response.text
new_snapshot = await resolve_account_memory_registry(fs, account_id, defaults)
assert new_snapshot.get("events").content_template == "# {{ summary }}"
assert new_snapshot.get("events")._account_content_template is False
async def test_account_memory_templates_old_content_can_be_read_replaced_and_reset(
lightweight_admin_client,
lightweight_admin_app,
template_account,
):
from openviking_cli.exceptions import FailedPreconditionError
account_id, headers = template_account
fs = lightweight_admin_app.state.fake_service.viking_fs
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/events"
path = account_memory_template_path(account_id, "events")
response = await lightweight_admin_client.put(
url, json={"description": "Account instructions"}, headers=headers
)
assert response.status_code == 200, response.text
legacy = yaml.safe_load(fs.agfs._files[path])
legacy["content_template"] = "{{ summary | length }}"
raw = yaml.safe_dump(legacy).encode()
for operation in ("PUT", "DELETE"):
fs.agfs._files[path] = raw
response = await lightweight_admin_client.get(url, headers=headers)
assert response.status_code == 200
assert (
response.json()["result"]["effective"]["content_template"] == legacy["content_template"]
)
with pytest.raises(FailedPreconditionError):
await resolve_account_memory_registry(fs, account_id, MemoryTypeRegistry())
response = await lightweight_admin_client.request(
operation,
url,
headers=headers,
**(
{"json": {"content_template": "{{ summary.upper() }}"}}
if operation == "PUT"
else {}
),
)
assert response.status_code == 200, response.text
assert fs.agfs._files[path + ".backup"] == raw
@pytest.mark.parametrize(
"memory_type", ["profile", "events", "preferences", "entities", "soul", "identity"]
)
async def test_account_memory_templates_locked_fields_cannot_be_modified(
lightweight_admin_client,
lightweight_admin_app,
template_account,
memory_type,
):
client = lightweight_admin_client
account_id, headers = template_account
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/{memory_type}"
active = await client.put(url, json={"description": "Active policy"}, headers=headers)
assert active.status_code == 200, active.text
defaults = active.json()["result"]["defaults"]
fs = lightweight_admin_app.state.fake_service.viking_fs
original = dict(fs.agfs._files)
editable = {"description", "fields"}
if memory_type in {"events", "soul", "identity"}:
editable.add("content_template")
invalid = [
{key: None if value is not None else "changed"}
for key, value in defaults.items()
if key not in editable
]
invalid.extend(
[
{"unknown": "ignored?"},
{"Description": "wrong key"},
{"_updated_at": "forged"},
{"_account_content_template": False},
{"deployment_content_template": "forged"},
{"enabled": 1},
{"fields": [{"name": "renamed_field", "description": "changed"}]},
{"description": None},
{"description": " \n"},
{"fields": [None]},
]
)
for field in defaults["fields"]:
invalid.extend(
{"fields": [{"name": field["name"], key: None if value is not None else "changed"}]}
for key, value in field.items()
if key not in {"name", "description"}
)
invalid.extend(
[
{"fields": [{"name": field["name"], "unknown": "ignored?"}]},
{"fields": [{"name": field["name"], "description": ""}]},
{"fields": [{"name": field["name"]}, {"name": field["name"]}]},
]
)
if memory_type == "events":
invalid.extend(
{"fields": [{"name": name, "description": "changed"}]} for name in ("goal", "ranges")
)
if memory_type in {"events", "soul", "identity"}:
invalid.extend([{"content_template": None}, {"content_template": " "}])
for body in invalid:
response = await client.put(url, json=body, headers=headers)
assert response.status_code == 400, (memory_type, body, response.text)
assert response.json()["error"]["code"] == "INVALID_ARGUMENT"
assert fs.agfs._files == original
# Partial field lists are updates by name, never a way to remove locked fields.
response = await client.put(url, json={"fields": []}, headers=headers)
assert response.status_code == 200, response.text
assert response.json()["result"]["effective"]["fields"] == defaults["fields"]
response = await client.put(
url, json={"fields": list(reversed(defaults["fields"]))}, headers=headers
)
assert response.status_code == 200, response.text
assert response.json()["result"]["effective"]["fields"] == defaults["fields"]
@pytest.mark.parametrize("memory_type", ["experiences", "cases", "trajectories"])
async def test_account_memory_templates_unexposed_types_rejected(
lightweight_admin_client,
lightweight_admin_app,
template_account,
memory_type,
):
account_id, headers = template_account
fs = lightweight_admin_app.state.fake_service.viking_fs
original = dict(fs.agfs._files)
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/{memory_type}"
for method in ("GET", "PUT", "DELETE"):
response = await lightweight_admin_client.request(
method,
url,
headers=headers,
**({"json": {"description": "New"}} if method == "PUT" else {}),
)
assert response.status_code == 400, response.text
assert fs.agfs._files == original
async def test_account_memory_templates_permissions_and_isolation(
lightweight_admin_client,
lightweight_admin_app,
template_account,
):
client = lightweight_admin_client
account_id, admin_headers = template_account
other = _uid()
created = await client.post(
"/api/v1/admin/accounts",
json={"account_id": other, "admin_user_id": "alice"},
headers=root_headers(),
)
assert created.status_code == 200
user = await client.post(
f"/api/v1/admin/accounts/{account_id}/users", json={"user_id": "bob"}, headers=admin_headers
)
assert user.status_code == 200
user_headers = {"X-API-Key": user.json()["result"]["user_key"]}
for method in ("GET", "PUT", "DELETE"):
body = {"json": {"description": "custom"}} if method == "PUT" else {}
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/profile"
assert (await client.request(method, url, headers=user_headers, **body)).status_code == 403
other_url = f"/api/v1/admin/accounts/{other}/memory-templates/profile"
assert (
await client.request(method, other_url, headers=admin_headers, **body)
).status_code == 403
missing_url = "/api/v1/admin/accounts/missing-account/memory-templates/profile"
assert (
await client.request(method, missing_url, headers=root_headers(), **body)
).status_code == 404
assert (
await client.request(
method, url.replace("/profile", "/missing-template"), headers=admin_headers, **body
)
).status_code == 404
root = f"/api/v1/admin/accounts/{account_id}/memory-templates"
assert (await client.get(root, headers=user_headers)).status_code == 403
for memory_type in ("profile", "events"):
assert (
await client.put(
f"{root}/{memory_type}", json={"description": memory_type}, headers=root_headers()
)
).status_code == 200
await client.delete(f"{root}/profile", headers=admin_headers)
assert (await client.get(f"{root}/events", headers=admin_headers)).json()["result"][
"effective"
]["description"] == "events"
other_result = await client.get(
f"/api/v1/admin/accounts/{other}/memory-templates", headers=root_headers()
)
assert all(
item["status"] == "system_default" for item in other_result.json()["result"]["templates"]
)
@pytest.mark.parametrize("merge", [False, True], ids=["extraction", "patch-merge"])
@pytest.mark.parametrize("output_format", ["python", "json"])
@pytest.mark.parametrize(
"marker",
[
"{{ language.upper() }}",
"{% if language == 'en' %}EN{% else %}OTHER{% endif %}",
"{{ language | trim | upper }}",
],
ids=["safe-method", "safe-condition", "safe-filters"],
)
async def test_account_memory_templates_reach_live_prompts(
lightweight_admin_client,
lightweight_admin_app,
template_account,
merge,
output_format,
marker,
monkeypatch,
):
monkeypatch.setattr(get_openviking_config().memory, "extraction_output_format", output_format)
account_id, headers = template_account
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/profile"
fs = lightweight_admin_app.state.fake_service.viking_fs
registry = MemoryTypeRegistry()
base_description = registry.get("profile").description
async def prompt_for(account, user, peer=None):
snapshot = await resolve_account_memory_registry(fs, account, registry)
ctx = RequestContext(user=UserIdentifier(account, user), role=Role.USER, actor_peer_id=peer)
if merge:
provider = PatchMergeContextProvider(
memory_type="profile", patches=[], memory_registry=snapshot
)
else:
provider = SessionExtractContextProvider(
messages=[], ctx=ctx, viking_fs=fs, memory_registry=snapshot
)
provider._output_language = "en"
provider.prefetch = AsyncMock(return_value=[])
vlm = Mock(
model="test-memory-templates",
get_completion_async=AsyncMock(
return_value="sdk.commit()" if output_format == "python" else '{"delete_ids":[]}'
),
)
isolation = MemoryIsolationHandler(
ctx,
provider.get_extract_context(),
allowed_memory_types={"profile"},
allowed_peer_ids={peer} if peer else None,
)
isolation.prepare_messages()
provider._isolation_handler = isolation
loop = ExtractLoop(
vlm=vlm, viking_fs=fs, ctx=ctx, context_provider=provider, isolation_handler=isolation
)
operations, _ = await loop.run()
assert not operations.has_errors()
return vlm.get_completion_async.await_args.kwargs["messages"][0]["content"]
initial = await prompt_for(account_id, "alice")
# Account descriptions render through the same restricted schema path.
body = {
"description": "CUSTOM_ACCOUNT_SCOPE " + marker,
"fields": [
{
"name": "content",
"description": "ACCOUNT_FIELD " + marker + " {{ language }}",
}
],
}
assert (await lightweight_admin_client.put(url, json=body, headers=headers)).status_code == 200
for user, peer in (("alice", None), ("bob", None), ("bob", "customer")):
prompt = await prompt_for(account_id, user, peer)
assert "CUSTOM_ACCOUNT_SCOPE EN" in prompt
assert "ACCOUNT_FIELD EN en" in prompt
assert "CUSTOM_ACCOUNT_SCOPE" not in await prompt_for("other-account", "alice")
assert registry.get("profile").description == base_description
assert (await lightweight_admin_client.delete(url, headers=headers)).status_code == 200
assert await prompt_for(account_id, "alice") == initial
async def test_account_memory_templates_commit_keeps_snapshot_through_file_write(
lightweight_admin_client,
lightweight_admin_app,
template_account,
monkeypatch,
):
from openviking.message import Message, TextPart
from openviking.session.compressor_v3 import SessionCompressorV3
from openviking.session.memory.dataclass import ResolvedOperation, ResolvedOperations
from openviking.session.memory.streaming_memory_updater import (
StreamingMemoryUpdater,
StreamingMemoryUpdaterConfig,
)
from openviking.session.memory.utils.memory_file_utils import MemoryFileUtils
account_id, headers = template_account
fs = lightweight_admin_app.state.fake_service.viking_fs
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/soul"
template = {
"description": "ACCOUNT_DESCRIPTION",
"content_template": "# OLD_TEMPLATE\n{{ core_truths }}",
"fields": [{"name": "core_truths", "description": "Business core values"}],
}
assert (
await lightweight_admin_client.put(url, json=template, headers=headers)
).status_code == 200
for module in (
"openviking.session.compressor_v3",
"openviking.session.memory.memory_updater",
"openviking.session.memory.streaming_memory_updater",
"openviking.storage.viking_fs",
):
monkeypatch.setattr(f"{module}.get_viking_fs", lambda: fs)
initialized = []
async def initialize(registry, ctx, allowed_memory_types=None):
initialized.append(registry.get("soul").filename_template)
monkeypatch.setattr(MemoryTypeRegistry, "initialize_memory_files", initialize)
# Deliberately retain a cached deployment registry: request snapshots must win.
updater = StreamingMemoryUpdater(
registry=MemoryTypeRegistry(),
config=StreamingMemoryUpdaterConfig(max_operations_per_update=1),
)
monkeypatch.setattr(
"openviking.session.compressor_v3.get_streaming_memory_updater",
AsyncMock(return_value=updater),
)
ctx = RequestContext(user=UserIdentifier(account_id, "alice"), role=Role.USER)
uri = "viking://user/alice/memories/soul.md"
def orchestrator(**kwargs):
provider = kwargs["context_provider"]
assert provider.get_memory_schemas(ctx)[0].description == "ACCOUNT_DESCRIPTION"
async def run():
# A publish during the LLM call must not switch the writer's template.
changed = {**template, "content_template": "# NEW_TEMPLATE\n{{ core_truths }}"}
assert (
await lightweight_admin_client.put(url, json=changed, headers=headers)
).status_code == 200
return ResolvedOperations(
delete_file_contents=[],
errors=[],
upsert_operations=[
ResolvedOperation(
memory_type="soul",
uris=[uri],
memory_fields={"core_truths": "Business fact"},
)
],
), []
return Mock(run=run)
vlm = Mock()
resolver = Mock(get_vlm=AsyncMock(return_value=vlm))
compressor = SessionCompressorV3(vikingdb=None, vlm_resolver=resolver)
monkeypatch.setattr(compressor, "_get_or_create_react", orchestrator)
try:
await compressor._extract_user_memories(
messages=[Message(id="m1", role="user", parts=[TextPart("Business fact")])],
ctx=ctx,
allowed_memory_types={"soul"},
)
finally:
await updater.close()
resolver.get_vlm.assert_awaited_once_with(account_id)
assert initialized == ["soul.md"]
content = MemoryFileUtils.read(fs.files[uri], uri=uri).content
assert "# OLD_TEMPLATE" in content and "Business fact" in content
assert "# NEW_TEMPLATE" not in content
async def test_account_memory_templates_write_failure_preserves_active_configuration(
lightweight_admin_client,
lightweight_admin_app,
template_account,
monkeypatch,
):
account_id, headers = template_account
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/profile"
client = lightweight_admin_client
assert (
await client.put(url, json={"description": "Active"}, headers=headers)
).status_code == 200
fs = lightweight_admin_app.state.fake_service.viking_fs
path = account_memory_template_path(account_id, "profile")
original = fs.agfs._files[path]
write = fs.agfs.write
failed = False
def fail_once(target, content, **kwargs):
nonlocal failed
if target.startswith(path + ".") and target.endswith(".tmp") and not failed:
failed = True
fs.agfs._files[target] = b"partial"
raise OSError("simulated write failure")
return write(target, content, **kwargs)
monkeypatch.setattr(fs.agfs, "write", fail_once)
with pytest.raises(OSError, match="simulated write failure"):
await client.put(url, json={"description": "Unpublished"}, headers=headers)
assert fs.agfs._files[path] == original
assert (await client.get(url, headers=headers)).json()["result"]["effective"][
"description"
] == "Active"
@pytest.fixture
def template_path_lock(
lightweight_admin_app,
template_account,
monkeypatch,
):
account_id, _ = template_account
fs = lightweight_admin_app.state.fake_service.viking_fs
path = account_memory_template_path(account_id, "profile")
lock = threading.Lock()
def acquire(ctx, target, timeout_secs=0, owner_lease_ref=None):
from openviking.storage.errors import LockAcquisitionError
assert target == path
assert ctx["account_id"] == account_id
if not lock.acquire(timeout=timeout_secs):
raise LockAcquisitionError("publication in progress")
return {"lease_ref": "publication-test"}
def release(ctx, lease):
lock.release()
monkeypatch.setattr(fs.agfs, "pathlock_acquire_exact", acquire)
monkeypatch.setattr(fs.agfs, "pathlock_release", release)
return lock
async def test_account_memory_templates_concurrent_publications_keep_both_types(
lightweight_admin_client,
template_account,
):
account_id, headers = template_account
root = f"/api/v1/admin/accounts/{account_id}/memory-templates"
responses = await asyncio.gather(
lightweight_admin_client.put(
f"{root}/profile", json={"description": "Profile override"}, headers=headers
),
lightweight_admin_client.put(
f"{root}/events", json={"description": "Events override"}, headers=headers
),
)
assert [response.status_code for response in responses] == [200, 200]
result = (await lightweight_admin_client.get(root, headers=headers)).json()["result"]
effective = {item["memory_type"]: item["effective"] for item in result["templates"]}
assert effective["profile"]["description"] == "Profile override"
assert effective["events"]["description"] == "Events override"
async def test_account_memory_templates_read_does_not_wait_for_publication(
lightweight_admin_client,
lightweight_admin_app,
template_account,
template_path_lock,
monkeypatch,
):
account_id, headers = template_account
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/profile"
client = lightweight_admin_client
assert (
await client.put(url, json={"description": "Active"}, headers=headers)
).status_code == 200
fs = lightweight_admin_app.state.fake_service.viking_fs
path = account_memory_template_path(account_id, "profile")
acquiring = threading.Event()
acquire = fs.agfs.pathlock_acquire_exact
def observe_acquire(*args, **kwargs):
acquiring.set()
return acquire(*args, **kwargs)
monkeypatch.setattr(fs.agfs, "pathlock_acquire_exact", observe_acquire)
with template_path_lock:
# A writer may be preparing a partial staging file, but the active path
# remains a complete version and readers never request its lock.
fs.agfs._files[path + ".test.tmp"] = b"partial"
response = await asyncio.wait_for(client.get(url, headers=headers), timeout=1)
assert not acquiring.is_set()
assert response.status_code == 200, response.text
assert response.json()["result"]["effective"]["description"] == "Active"
async def test_account_memory_templates_reject_oversized_serialized_config(
lightweight_admin_client,
lightweight_admin_app,
template_account,
monkeypatch,
):
account_id, headers = template_account
fs = lightweight_admin_app.state.fake_service.viking_fs
original = dict(fs.agfs._files)
monkeypatch.setattr("openviking.session.memory.account_templates._MAX_CONFIG_BYTES", 100)
response = await lightweight_admin_client.put(
f"/api/v1/admin/accounts/{account_id}/memory-templates/profile",
json={"description": "A valid description"},
headers=headers,
)
assert response.status_code == 400, response.text
assert fs.agfs._files == original
async def test_account_memory_templates_corrupt_storage_is_not_overwritten(
lightweight_admin_client,
lightweight_admin_app,
template_account,
):
account_id, headers = template_account
fs = lightweight_admin_app.state.fake_service.viking_fs
path = account_memory_template_path(account_id, "profile")
fs.agfs._files[path] = b'{"invalid-json"'
original = dict(fs.agfs._files)
url = f"/api/v1/admin/accounts/{account_id}/memory-templates/profile"
for method in ("GET", "PUT", "DELETE"):
response = await lightweight_admin_client.request(
method,
url,
headers=headers,
**({"json": {"description": "New"}} if method == "PUT" else {}),
)
assert response.status_code == 412, response.text
assert response.json()["error"]["code"] == "FAILED_PRECONDITION"
assert fs.agfs._files == original
async def test_create_account(
admin_client: httpx.AsyncClient, admin_service: OpenVikingService, monkeypatch
):
"""ROOT provisioning applies account settings before initializing storage."""
settings = {
"embedding": {"max_retries": 5},
"vectordb": {
"backend": "vikingdb",
"name": "account_context",
"index_name": "default",
"dimension": 1024,
"vikingdb": {"host": "https://account.invalid"},
},
}
adapter = Mock(mode="vikingdb", USE_CONTENT_FIELD=True)
adapter.get.return_value = []
adapter.get_collection.return_value.get_meta_data.return_value = {
"Fields": [{"FieldName": name} for name in (
"id", "uri", "account_id", "context_type", "abstract", "level",
"user", "agent", "vector", "sparse_vector", "created_at", "updated_at",
)]
}
adapter.upsert.side_effect = lambda rows: [row["id"] for row in rows]
factory = Mock(return_value=adapter)
monkeypatch.setattr(
"openviking.storage.viking_vector_index_backend.create_collection_adapter",
factory,
)
acct = _uid()
resp = await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice", "settings": settings},
headers=root_headers(),
)
assert resp.status_code == 200
body = resp.json()
assert body["result"]["account_id"] == acct
assert body["result"]["admin_user_id"] == "alice"
assert "user_key" in body["result"]
ctx = RequestContext(user=UserIdentifier(acct, "alice"), role=Role.ADMIN)
assert await admin_service.viking_fs.abstract("viking://resources", ctx=ctx)
assert await admin_service.viking_fs.abstract("viking://user", ctx=ctx)
stored = await admin_client.get(
f"/api/v1/admin/accounts/{acct}/configuration", headers=root_headers()
)
assert stored.json()["result"]["settings"] == settings
effective = await admin_service.vector_config_resolver.resolve(acct)
assert effective.dedicated_vectordb
assert effective.vectordb.name == "account_context"
assert effective.embedding.max_retries == 5
assert factory.call_args.args[0].vikingdb.host == "https://account.invalid"
adapter.create_collection.assert_not_called()
async def test_create_account_rolls_back_when_runtime_config_write_fails(
admin_client: httpx.AsyncClient,
admin_service: OpenVikingService,
admin_app: FastAPI,
monkeypatch: pytest.MonkeyPatch,
):
"""A post-registry initialization failure must not leave a half-created account."""
acct = _uid()
async def fail_patch(*args, **kwargs):
raise OSError("runtime config unavailable")
monkeypatch.setattr(
admin_service.runtime_config_manager,
"patch_account",
fail_patch,
)
response = await admin_client.post(
"/api/v1/admin/accounts",
json={
"account_id": acct,
"admin_user_id": "alice",
"settings": {"github": {"token": "account-token"}},
},
headers=root_headers(),
)
assert response.status_code == 500
assert not any(item["account_id"] == acct for item in admin_app.state.api_key_manager.get_accounts())
assert not await _agfs_exists(admin_service, f"/local/{acct}")
async def test_create_user_paths_accept_initial_user_config(
lightweight_admin_client: httpx.AsyncClient,
lightweight_admin_app: FastAPI,
):
acct = _uid()
viking_fs = lightweight_admin_app.state.fake_service.viking_fs
resp = await lightweight_admin_client.post(
"/api/v1/admin/accounts",
json={
"account_id": acct,
"admin_user_id": "alice",
# Legacy uid-less spelling, normalized to the viking://~ home alias.
"user_config": {"add_targets": {"resource_uri": "viking://user/resources/admin"}},
},
headers=root_headers(),
)
assert resp.status_code == 200, resp.text
alice_settings = await read_user_add_targets(
viking_fs,
RequestContext(user=UserIdentifier(acct, "alice"), role=Role.ADMIN),
)
assert alice_settings.resource_uri == "viking://user/alice/resources/admin"
resp = await lightweight_admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={
"user_id": "bob",
"role": "user",
"user_config": {"add_targets": {"resource_uri": "viking://~/resources/bob"}},
},
headers=root_headers(),
)
assert resp.status_code == 200, resp.text
bob_settings = await read_user_add_targets(
viking_fs,
RequestContext(user=UserIdentifier(acct, "bob"), role=Role.USER),
)
assert bob_settings.resource_uri == "viking://user/bob/resources/bob"
async def test_user_memory_policy_can_be_initialized_and_hot_updated(
lightweight_admin_client: httpx.AsyncClient,
lightweight_admin_app: FastAPI,
):
acct = _uid()
create_account = await lightweight_admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
assert create_account.status_code == 200, create_account.text
create_user = await lightweight_admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={
"user_id": "bob",
"role": "user",
"user_config": {"memory_policy": {"memory_types": ["profile"]}},
},
headers=root_headers(),
)
assert create_user.status_code == 200, create_user.text
get_settings = await lightweight_admin_client.get(
f"/api/v1/admin/accounts/{acct}/users/bob/settings",
headers=root_headers(),
)
assert get_settings.status_code == 200, get_settings.text
assert get_settings.json()["result"]["memory_policy"] == {
"self": {"enabled": True},
"peer": {"enabled": True},
"memory_types": ["profile"],
}
patch_settings = await lightweight_admin_client.patch(
f"/api/v1/admin/accounts/{acct}/users/bob/settings",
json={"memory_policy": {"memory_types": ["experiences"]}},
headers=root_headers(),
)
assert patch_settings.status_code == 200, patch_settings.text
memory_policy = patch_settings.json()["result"]["memory_policy"]
assert memory_policy["memory_types"] == [
"cases",
"experiences",
"trajectories",
]
viking_fs = lightweight_admin_app.state.fake_service.viking_fs
persisted = await read_user_config(
viking_fs,
RequestContext(user=UserIdentifier(acct, "bob"), role=Role.USER),
)
assert persisted.memory_policy["memory_types"] == [
"cases",
"experiences",
"trajectories",
]
user_ctx = RequestContext(user=UserIdentifier(acct, "bob"), role=Role.USER)
backup = json.loads(viking_fs.files[user_config_backup_uri(user_ctx)])
assert backup["memory_policy"]["memory_types"] == ["profile"]
writes_before_noop = len(viking_fs.writes)
noop_patch = await lightweight_admin_client.patch(
f"/api/v1/admin/accounts/{acct}/users/bob/settings",
json={"memory_policy": persisted.memory_policy},
headers=root_headers(),
)
assert noop_patch.status_code == 200, noop_patch.text
assert len(viking_fs.writes) == writes_before_noop
lightweight_admin_app.state.config.user_config_defaults = UserConfig(
memory_policy={"memory_types": ["events"]}
)
reset_settings = await lightweight_admin_client.patch(
f"/api/v1/admin/accounts/{acct}/users/bob/settings",
json={"memory_policy": None},
headers=root_headers(),
)
assert reset_settings.status_code == 200, reset_settings.text
assert reset_settings.json()["result"]["memory_policy"] == {
"self": {"enabled": True},
"peer": {"enabled": True},
"memory_types": ["events"],
}
persisted = await read_user_config(viking_fs, user_ctx)
assert persisted.memory_policy is None
backup = json.loads(viking_fs.files[user_config_backup_uri(user_ctx)])
assert backup["memory_policy"]["memory_types"] == [
"cases",
"experiences",
"trajectories",
]
async def test_user_memory_policy_uses_server_default_without_user_override(
lightweight_admin_client: httpx.AsyncClient,
lightweight_admin_app: FastAPI,
):
acct = _uid()
lightweight_admin_app.state.config.user_config_defaults = UserConfig(
memory_policy={"memory_types": ["profile"]}
)
create_account = await lightweight_admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
assert create_account.status_code == 200, create_account.text
get_settings = await lightweight_admin_client.get(
f"/api/v1/admin/accounts/{acct}/users/alice/settings",
headers=root_headers(),
)
assert get_settings.status_code == 200, get_settings.text
assert get_settings.json()["result"]["memory_policy"] == {
"self": {"enabled": True},
"peer": {"enabled": True},
"memory_types": ["profile"],
}
async def test_create_user_paths_ignore_deprecated_agent_evolution_config(
lightweight_admin_client: httpx.AsyncClient,
lightweight_admin_app: FastAPI,
):
acct = _uid()
viking_fs = lightweight_admin_app.state.fake_service.viking_fs
resp = await lightweight_admin_client.post(
"/api/v1/admin/accounts",
json={
"account_id": acct,
"admin_user_id": "alice",
"user_config": {"agent_evolution": {"enabled": True}},
},
headers=root_headers(),
)
assert resp.status_code == 200, resp.text
alice_config = await read_user_config(
viking_fs,
RequestContext(user=UserIdentifier(acct, "alice"), role=Role.ADMIN),
)
assert alice_config.agent_evolution.enabled is None
resp = await lightweight_admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={
"user_id": "bob",
"role": "user",
"user_config": {"agent_evolution": {"enabled": False}},
},
headers=root_headers(),
)
assert resp.status_code == 200, resp.text
bob_config = await read_user_config(
viking_fs,
RequestContext(user=UserIdentifier(acct, "bob"), role=Role.USER),
)
assert bob_config.agent_evolution.enabled is None
async def test_list_accounts(admin_client: httpx.AsyncClient):
"""ROOT can list all accounts."""
acct = _uid()
await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
resp = await admin_client.get("/api/v1/admin/accounts", headers=root_headers())
assert resp.status_code == 200
accounts = resp.json()["result"]
account_ids = {a["account_id"] for a in accounts}
assert "default" in account_ids
assert acct in account_ids
# `query` is a case-insensitive substring match on the account id.
fragment = acct[:5].upper() # "ACME_", proving the match ignores case
resp = await admin_client.get(
"/api/v1/admin/accounts",
params={"query": fragment},
headers=root_headers(),
)
assert resp.status_code == 200
queried_ids = {a["account_id"] for a in resp.json()["result"]}
assert acct in queried_ids
assert "default" not in queried_ids
async def test_list_accounts_without_watcher_reads_only_accounts_registry(
lightweight_admin_client: httpx.AsyncClient,
lightweight_admin_app: FastAPI,
monkeypatch: pytest.MonkeyPatch,
):
"""Without a watcher, account listing must not load user registries."""
manager = lightweight_admin_app.state.api_key_manager
original_read_json = manager._legacy._read_json
read_paths: list[str] = []
async def _record_read(path: str):
read_paths.append(path)
return await original_read_json(path)
monkeypatch.setattr(manager._legacy, "_read_json", _record_read)
resp = await lightweight_admin_client.get(
"/api/v1/admin/accounts?limit=5&page=1", headers=root_headers()
)
assert resp.status_code == 200, resp.text
assert read_paths == ["/local/_system/accounts.json"]
async def test_list_users_without_watcher_reads_only_target_user_registry(
lightweight_admin_client: httpx.AsyncClient,
lightweight_admin_app: FastAPI,
monkeypatch: pytest.MonkeyPatch,
):
"""Without a watcher, user listing must not load unrelated registries."""
acct = _uid()
await lightweight_admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
manager = lightweight_admin_app.state.api_key_manager
original_read_json = manager._legacy._read_json
read_paths: list[str] = []
async def _record_read(path: str):
read_paths.append(path)
return await original_read_json(path)
monkeypatch.setattr(manager._legacy, "_read_json", _record_read)
resp = await lightweight_admin_client.get(
f"/api/v1/admin/accounts/{acct}/users", headers=root_headers()
)
assert resp.status_code == 200, resp.text
assert read_paths == [f"/local/{acct}/_system/users.json"]
async def test_list_users_for_default_account_without_users_file_returns_empty_list(
lightweight_admin_client: httpx.AsyncClient,
):
"""The initialized default account may legitimately have no users.json."""
resp = await lightweight_admin_client.get(
"/api/v1/admin/accounts/default/users", headers=root_headers()
)
assert resp.status_code == 200, resp.text
assert resp.json()["result"] == []
async def test_list_accounts_with_watcher_uses_memory(
lightweight_admin_client: httpx.AsyncClient,
lightweight_admin_app: FastAPI,
monkeypatch: pytest.MonkeyPatch,
):
"""A running watcher keeps list requests on the in-memory fast path."""
loop = asyncio.get_running_loop()
watch_task = loop.create_future()
lightweight_admin_app.state.auth_plugin._watch_task = watch_task
async def _unexpected_read(_path: str):
raise AssertionError("list accounts should not read AGFS while watcher is running")
manager = lightweight_admin_app.state.api_key_manager
monkeypatch.setattr(manager._legacy, "_read_json", _unexpected_read)
try:
resp = await lightweight_admin_client.get(
"/api/v1/admin/accounts", headers=root_headers()
)
users_resp = await lightweight_admin_client.get(
"/api/v1/admin/accounts/default/users", headers=root_headers()
)
finally:
watch_task.cancel()
assert resp.status_code == 200, resp.text
assert users_resp.status_code == 200, users_resp.text
async def test_identity_settings_refreshes_a_stale_registry_on_demand(
admin_client: httpx.AsyncClient,
admin_app: FastAPI,
admin_service: OpenVikingService,
):
"""Detail settings reads must not depend on a preceding list request."""
replica = admin_app.state.api_key_manager
writer = APIKeyManager(
root_key=ROOT_KEY,
viking_fs=admin_service.viking_fs,
)
await writer.load()
acct = _uid()
await writer.ensure_trusted_identities({acct: {"trusted-user"}})
assert replica.has_user(acct, "trusted-user") is False
account_settings = await admin_client.get(
f"/api/v1/admin/accounts/{acct}/settings",
headers=root_headers(),
)
assert account_settings.status_code == 200, account_settings.text
assert replica.has_user(acct, "trusted-user") is False
await writer.ensure_trusted_identities({acct: {"trusted-user-2"}})
assert replica.has_user(acct, "trusted-user-2") is False
user_settings = await admin_client.get(
f"/api/v1/admin/accounts/{acct}/users/trusted-user-2/settings",
headers=root_headers(),
)
assert user_settings.status_code == 200, user_settings.text
assert replica.has_user(acct, "trusted-user") is True
assert replica.has_user(acct, "trusted-user-2") is True
async def test_identity_settings_without_watcher_use_scoped_registry_reads(
lightweight_admin_client: httpx.AsyncClient,
lightweight_admin_app: FastAPI,
monkeypatch: pytest.MonkeyPatch,
):
"""Settings checks must not fall back to a full identity-registry reload."""
acct = _uid()
await lightweight_admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
manager = lightweight_admin_app.state.api_key_manager
original_read_json = manager._legacy._read_json
read_paths: list[str] = []
async def _record_read(path: str):
read_paths.append(path)
return await original_read_json(path)
monkeypatch.setattr(manager._legacy, "_read_json", _record_read)
account_resp = await lightweight_admin_client.get(
f"/api/v1/admin/accounts/{acct}/settings", headers=root_headers()
)
assert account_resp.status_code == 200, account_resp.text
assert read_paths == ["/local/_system/accounts.json"]
read_paths.clear()
user_resp = await lightweight_admin_client.get(
f"/api/v1/admin/accounts/{acct}/users/alice/settings",
headers=root_headers(),
)
assert user_resp.status_code == 200, user_resp.text
assert read_paths == [
"/local/_system/accounts.json",
f"/local/{acct}/_system/users.json",
]
@pytest.mark.parametrize("index_updates_immediately", [True, False])
async def test_delete_account(
admin_client, admin_service, admin_app, monkeypatch, index_updates_immediately
):
"""Cleanup submits all IDs without depending on immediate query consistency."""
from itertools import islice
from types import SimpleNamespace
from openviking.storage.expr import Eq
from openviking.storage.queuefs.queue_manager import QueueManager
from openviking.storage.vectordb_adapters.local_adapter import LocalCollectionAdapter
from openviking.storage.viking_vector_index_backend import _SingleAccountBackend
acct = _uid()
resp = await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
key = resp.json()["result"]["user_key"]
path = f"/local/{acct}/resources/project/nested/file.md"
await _agfs_write(admin_service, path, "project data")
await _agfs_write(admin_service, "/local/other/resources/keep.md", "keep")
old_task = await get_task_tracker().create(
"session_commit", account_id=acct, user_id="alice", resource_id="old-session"
)
user_cleanup = await get_task_tracker().create(
"user_delete", account_id=acct, user_id="alice", resource_id=f"{acct}/bob"
)
manager = admin_app.state.api_key_manager
# Exercise the real filter-deletion loop with bounded in-memory I/O.
rows = {str(i): {"id": str(i), "account_id": acct} for i in range(100_001)}
rows["keep"] = {"id": "keep", "account_id": "other"}
indexed_rows = rows.copy()
deleted_ids = []
adapter = LocalCollectionAdapter("context", "", "default")
def delete_data(ids):
assert 0 < len(ids) <= 100
deleted_ids.extend(ids)
for record_id in ids:
rows.pop(record_id, None)
adapter._collection = SimpleNamespace(delete_data=delete_data)
scan_rows = rows if index_updates_immediately else indexed_rows
adapter.query = lambda *, filter, limit, offset=0, **kwargs: list(
islice(
(row for row in scan_rows.values() if row[filter.field] == filter.value),
offset,
offset + limit,
)
)
# Other read APIs may still expose data after delete requests succeed.
adapter.get = lambda ids: [
indexed_rows[record_id] for record_id in ids if record_id in indexed_rows
]
adapter.count = lambda filter: sum(
row[filter.field] == filter.value for row in indexed_rows.values()
)
vectors = admin_service.viking_fs.vector_store
vectors._resolved_backends[acct] = _SingleAccountBackend(
vectors._config, bound_account_id=acct, shared_adapter=adapter
)
original_delete = vectors.delete_account_data
started, release = asyncio.Event(), asyncio.Event()
async def fail_vectors(account_id, *, ctx):
started.set()
await release.wait()
raise RuntimeError("injected vector failure")
monkeypatch.setattr(vectors, "delete_account_data", fail_vectors)
resp = await admin_client.delete(f"/api/v1/admin/accounts/{acct}", headers=root_headers())
assert resp.status_code == 202
result = resp.json()["result"]
assert result["status"] == "deleting"
task_id = result["task_id"]
await asyncio.wait_for(started.wait(), 10)
duplicate = await admin_client.delete(f"/api/v1/admin/accounts/{acct}", headers=root_headers())
assert duplicate.json()["result"]["task_id"] == task_id
denied = await admin_client.get("/api/v1/fs/ls?uri=viking://", headers={"X-API-Key": key})
assert denied.status_code == 401
conflict = await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "new"},
headers=root_headers(),
)
assert conflict.status_code == 409
release.set()
failed = await _wait_for_task(admin_client, task_id)
assert failed["status"] == "failed"
assert "injected vector failure" in failed["error"]
assert await _agfs_exists(admin_service, path)
await manager.reload()
assert manager.get_deletion(acct)["task_id"] == task_id
retried = await admin_client.delete(f"/api/v1/admin/accounts/{acct}", headers=root_headers())
retry_task_id = retried.json()["result"]["task_id"]
assert retry_task_id != task_id
assert (await _wait_for_task(admin_client, retry_task_id))["status"] == "failed"
monkeypatch.setattr(vectors, "delete_account_data", original_delete)
# Reconcile a durable fence whose previous process stopped before enqueue.
retry = await manager.replace_deletion_task(
acct,
None,
expected_task_id=retry_task_id,
task_id=str(uuid.uuid4()),
owner_account_id=SYSTEM_TASK_ACCOUNT_ID,
owner_user_id=SYSTEM_TASK_USER_ID,
)
await admin_app.state.deletion_service.initialize()
completed = await _wait_for_task(admin_client, retry["task_id"])
assert completed["status"] == "completed", completed
assert completed["result"] == {"deleted": True}
assert set(rows) == {"keep"}
assert len(deleted_ids) == len(set(deleted_ids)) == 100_001
assert adapter.count(Eq("account_id", acct)) == 100_001
assert adapter.get(deleted_ids[:100])
assert not await _agfs_exists(admin_service, f"/local/{acct}")
assert await _agfs_exists(admin_service, "/local/other/resources/keep.md")
assert manager.get_deletion(acct) is None
assert not manager.has_user(acct, "alice")
assert await get_task_tracker().get(old_task.task_id, account_id=acct, user_id="alice") is None
assert await get_task_tracker().get(user_cleanup.task_id, account_id=acct, user_id="alice") is None
assert (await _wait_for_task(admin_client, task_id))["status"] == "failed"
# Late cleanup deliveries are harmless after deletion, including when the
# same account/user IDs have since been recreated.
queue = admin_service._queue_manager.get_queue(QueueManager.DATA_CLEANUP)
for recreated in (False, True):
if recreated:
replacement_key = await manager.create_account(acct, "bob")
await _agfs_write(admin_service, path, "new data")
late_task_id = str(uuid.uuid4())
await get_task_tracker().create(
"user_delete",
task_id=late_task_id,
resource_id=f"{acct}/bob",
account_id=SYSTEM_TASK_ACCOUNT_ID,
user_id=SYSTEM_TASK_USER_ID,
)
await queue.enqueue(
{
"task_id": user_cleanup.task_id,
"account_id": acct,
"user_id": "alice",
"target": {"account_id": acct, "user_id": "bob"},
}
)
await queue.enqueue(
{
"task_id": late_task_id,
"account_id": SYSTEM_TASK_ACCOUNT_ID,
"user_id": SYSTEM_TASK_USER_ID,
"target": {"account_id": acct, "user_id": "bob"},
}
)
skipped = await _wait_for_task(admin_client, late_task_id)
assert skipped["status"] == "completed"
assert skipped["result"] == {"deleted": not recreated, "stale": True}
assert await get_task_tracker().get(
user_cleanup.task_id, account_id=acct, user_id="alice"
) is None
assert await _agfs_exists(admin_service, path) is recreated
if recreated:
assert manager.resolve(replacement_key).user_id == "bob"
async def test_delete_account_retries_when_runtime_config_cleanup_fails(
admin_client: httpx.AsyncClient,
admin_service: OpenVikingService,
admin_app: FastAPI,
monkeypatch: pytest.MonkeyPatch,
):
"""Config cleanup failure keeps the deletion fence and can be retried."""
acct = _uid()
resp = await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
user_key = resp.json()["result"]["user_key"]
runtime_config = admin_service.runtime_config_manager
delete_config = AsyncMock(side_effect=OSError("provider unavailable"))
monkeypatch.setattr(runtime_config, "delete_account", delete_config)
resp = await admin_client.delete(
f"/api/v1/admin/accounts/{acct}",
headers=root_headers(),
)
assert resp.status_code == 202
failed = await _wait_for_task(admin_client, resp.json()["result"]["task_id"])
assert failed["status"] == "failed"
assert "provider unavailable" in failed["error"]
denied = await admin_client.get(
"/api/v1/fs/ls?uri=viking://",
headers={"X-API-Key": user_key},
)
assert denied.status_code == 401
assert admin_app.state.api_key_manager.get_deletion(acct) is not None
conflict = await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "replacement"},
headers=root_headers(),
)
assert conflict.status_code == 409
delete_config.side_effect = None
retry = await admin_client.delete(
f"/api/v1/admin/accounts/{acct}",
headers=root_headers(),
)
completed = await _wait_for_task(admin_client, retry.json()["result"]["task_id"])
assert completed["status"] == "completed"
assert admin_app.state.api_key_manager.get_deletion(acct) is None
assert delete_config.await_count == 2
async def test_create_duplicate_account_fails(admin_client: httpx.AsyncClient):
"""Creating duplicate account should fail."""
acct = _uid()
await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
resp = await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "bob"},
headers=root_headers(),
)
assert resp.status_code == 409 # ALREADY_EXISTS
# ---- User CRUD ----
async def test_register_user(admin_client: httpx.AsyncClient):
"""ROOT can register a user in an account."""
acct = _uid()
await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
resp = await admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={"user_id": "bob", "role": "user"},
headers=root_headers(),
)
assert resp.status_code == 200
body = resp.json()
assert body["result"]["user_id"] == "bob"
assert "user_key" in body["result"]
# Bob's key should work
bob_key = body["result"]["user_key"]
resp = await admin_client.get(
"/api/v1/fs/ls?uri=viking://",
headers={"X-API-Key": bob_key},
)
assert resp.status_code == 200
async def test_root_can_register_admin_role_user(
lightweight_admin_client: httpx.AsyncClient,
):
"""ROOT can create an ADMIN user via register_user."""
acct = _uid()
await lightweight_admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
resp = await lightweight_admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={"user_id": "bob-admin", "role": "admin"},
headers=root_headers(),
)
assert resp.status_code == 200
admin_key = resp.json()["result"]["user_key"]
list_users = await lightweight_admin_client.get(
f"/api/v1/admin/accounts/{acct}/users",
headers={"X-API-Key": admin_key},
)
assert list_users.status_code == 200
async def test_root_cannot_register_root_role_user(
lightweight_admin_client: httpx.AsyncClient,
):
"""ROOT is the configured server identity, not an account user role."""
acct = _uid()
await lightweight_admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
resp = await lightweight_admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={"user_id": "mallory-root", "role": "root"},
headers=root_headers(),
)
assert resp.status_code == 403
async def test_admin_can_register_user_in_own_account(admin_client: httpx.AsyncClient):
"""ADMIN can register users in their own account."""
acct = _uid()
resp = await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
alice_key = resp.json()["result"]["user_key"]
resp = await admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={"user_id": "bob", "role": "user"},
headers={"X-API-Key": alice_key},
)
assert resp.status_code == 200
async def test_admin_can_register_admin_role_user(
lightweight_admin_client: httpx.AsyncClient,
):
"""ADMIN can create another ADMIN in the same account via register_user."""
acct = _uid()
resp = await lightweight_admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
alice_key = resp.json()["result"]["user_key"]
resp = await lightweight_admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={"user_id": "mallory-admin", "role": "admin"},
headers={"X-API-Key": alice_key},
)
assert resp.status_code == 200
admin_key = resp.json()["result"]["user_key"]
list_users = await lightweight_admin_client.get(
f"/api/v1/admin/accounts/{acct}/users",
headers={"X-API-Key": admin_key},
)
assert list_users.status_code == 200
async def test_admin_cannot_register_root_role_user(
lightweight_admin_client: httpx.AsyncClient,
):
"""ADMIN should not be able to mint a ROOT key via register_user."""
acct = _uid()
resp = await lightweight_admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
alice_key = resp.json()["result"]["user_key"]
resp = await lightweight_admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={"user_id": "mallory-root", "role": "root"},
headers={"X-API-Key": alice_key},
)
assert resp.status_code == 403
async def test_admin_cannot_mint_root_key_that_reaches_root_only_endpoint(
lightweight_admin_client: httpx.AsyncClient,
):
"""ADMIN registration must never yield a key that works on ROOT-only endpoints."""
acct = _uid()
resp = await lightweight_admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
alice_key = resp.json()["result"]["user_key"]
resp = await lightweight_admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={"user_id": "mallory-root", "role": "root"},
headers={"X-API-Key": alice_key},
)
assert resp.status_code == 403
result = resp.json().get("result") or {}
assert "user_key" not in result
mallory_key = result.get("user_key")
if mallory_key:
root_only = await lightweight_admin_client.get(
"/api/v1/admin/accounts",
headers={"X-API-Key": mallory_key},
)
assert root_only.status_code == 403
async def test_admin_cannot_register_user_in_other_account(admin_client: httpx.AsyncClient):
"""ADMIN cannot register users in another account."""
acct = _uid()
other = _uid()
resp = await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
alice_key = resp.json()["result"]["user_key"]
await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": other, "admin_user_id": "eve"},
headers=root_headers(),
)
resp = await admin_client.post(
f"/api/v1/admin/accounts/{other}/users",
json={"user_id": "bob", "role": "user"},
headers={"X-API-Key": alice_key},
)
assert resp.status_code == 403
async def test_list_users(admin_client: httpx.AsyncClient):
"""ROOT can list users in an account."""
acct = _uid()
await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
await admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={"user_id": "bob", "role": "user"},
headers=root_headers(),
)
resp = await admin_client.get(f"/api/v1/admin/accounts/{acct}/users", headers=root_headers())
assert resp.status_code == 200
users = resp.json()["result"]
user_ids = {u["user_id"] for u in users}
assert user_ids == {"alice", "bob"}
async def test_remove_user(
admin_client: httpx.AsyncClient,
admin_service: OpenVikingService,
admin_app: FastAPI,
):
"""Deletion revokes the user and removes only their private data, uploads, and tasks."""
acct = _uid()
await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
resp = await admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={"user_id": "bob", "role": "user"},
headers=root_headers(),
)
bob_key = resp.json()["result"]["user_key"]
bob_ctx = RequestContext(user=UserIdentifier(acct, "bob"), role=Role.USER)
private_uri = "viking://user/bob/memories/private.md"
await admin_service.viking_fs.write_file(private_uri, "private", ctx=bob_ctx)
alice_upload_uri = "viking://upload/1800000000000-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
bob_upload_uri = "viking://upload/1800000000000-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
upload_ctx = RequestContext(user=UserIdentifier(acct, "alice"), role=Role.ROOT)
for upload_uri, user_id in ((alice_upload_uri, "alice"), (bob_upload_uri, "bob")):
await admin_service.viking_fs.write_file(
f"{upload_uri}/meta",
json.dumps({"account": acct, "user": user_id}),
ctx=upload_ctx,
)
bob_task = await get_task_tracker().create(
"session_commit",
resource_id="bob-session",
account_id=acct,
user_id="bob",
)
resp = await admin_client.delete(
f"/api/v1/admin/accounts/{acct}/users/bob", headers=root_headers()
)
assert resp.status_code == 202
task_id = resp.json()["result"]["task_id"]
# The fence invalidates Bob before background cleanup finishes.
resp = await admin_client.get(
"/api/v1/fs/ls?uri=viking://",
headers={"X-API-Key": bob_key},
)
assert resp.status_code == 401
deletion_task = await _wait_for_task(admin_client, task_id)
assert deletion_task["status"] == "completed"
assert not await admin_service.viking_fs.exists(private_uri, ctx=bob_ctx)
assert not await admin_service.viking_fs.exists(bob_upload_uri, ctx=upload_ctx)
assert await admin_service.viking_fs.exists(alice_upload_uri, ctx=upload_ctx)
assert not admin_app.state.api_key_manager.has_user(acct, "bob")
assert (
await get_task_tracker().get(
bob_task.task_id,
account_id=acct,
user_id="bob",
)
is None
)
missing = await admin_client.delete(
f"/api/v1/admin/accounts/{acct}/users/bob",
headers=root_headers(),
)
assert missing.status_code == 404
# ---- Role management ----
async def test_admin_can_set_user_role_in_own_account(
lightweight_admin_client: httpx.AsyncClient,
):
"""ADMIN can promote a user in its own account to ADMIN."""
acct = _uid()
create_account = await lightweight_admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
alice_key = create_account.json()["result"]["user_key"]
create_user = await lightweight_admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={"user_id": "bob", "role": "user"},
headers=root_headers(),
)
bob_key = create_user.json()["result"]["user_key"]
resp = await lightweight_admin_client.put(
f"/api/v1/admin/accounts/{acct}/users/bob/role",
json={"role": "admin"},
headers={"X-API-Key": alice_key},
)
assert resp.status_code == 200
assert resp.json()["result"]["role"] == "admin"
list_users = await lightweight_admin_client.get(
f"/api/v1/admin/accounts/{acct}/users",
headers={"X-API-Key": bob_key},
)
assert list_users.status_code == 200
invalid_role = await lightweight_admin_client.put(
f"/api/v1/admin/accounts/{acct}/users/bob/role",
json={"role": "root"},
headers={"X-API-Key": alice_key},
)
assert invalid_role.status_code == 400
async def test_regenerate_key(admin_client: httpx.AsyncClient):
"""ROOT can regenerate a user's key."""
acct = _uid()
await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
resp = await admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={"user_id": "bob", "role": "user"},
headers=root_headers(),
)
old_key = resp.json()["result"]["user_key"]
resp = await admin_client.post(
f"/api/v1/admin/accounts/{acct}/users/bob/key",
headers=root_headers(),
)
assert resp.status_code == 200
new_key = resp.json()["result"]["user_key"]
assert new_key != old_key
# Old key invalid
resp = await admin_client.get(
"/api/v1/fs/ls?uri=viking://",
headers={"X-API-Key": old_key},
)
assert resp.status_code == 401
# New key valid
resp = await admin_client.get(
"/api/v1/fs/ls?uri=viking://",
headers={"X-API-Key": new_key},
)
assert resp.status_code == 200
async def test_seeded_admin_key_endpoints(admin_client: httpx.AsyncClient):
from openviking.server.api_keys import parse_api_key
acct = _uid()
resp = await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice", "seed": "admin-seed"},
headers=root_headers(),
)
assert resp.status_code == 200
admin_key = resp.json()["result"]["user_key"]
account_id, user_id, secret = parse_api_key(admin_key)
assert account_id == acct
assert user_id == "alice"
assert secret == _seed_secret("alice", "admin-seed")
resp = await admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={"user_id": "bob", "role": "user", "seed": "bob-seed"},
headers=root_headers(),
)
assert resp.status_code == 200
old_key = resp.json()["result"]["user_key"]
_, _, old_secret = parse_api_key(old_key)
assert old_secret == _seed_secret("bob", "bob-seed")
resp = await admin_client.post(
f"/api/v1/admin/accounts/{acct}/users/bob/key",
json={"seed": "bob-new-seed"},
headers=root_headers(),
)
assert resp.status_code == 200
new_key = resp.json()["result"]["user_key"]
_, _, new_secret = parse_api_key(new_key)
assert new_secret == _seed_secret("bob", "bob-new-seed")
assert new_key != old_key
resp = await admin_client.get(
"/api/v1/fs/ls?uri=viking://",
headers={"X-API-Key": old_key},
)
assert resp.status_code == 401
async def test_empty_seed_rejected(admin_client: httpx.AsyncClient):
acct = _uid()
resp = await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice", "seed": ""},
headers=root_headers(),
)
assert resp.status_code == 400
# ---- Permission guard ----
async def test_user_role_cannot_access_admin_api(admin_client: httpx.AsyncClient):
"""USER role should not access admin endpoints."""
acct = _uid()
await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
resp = await admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={"user_id": "bob", "role": "user"},
headers=root_headers(),
)
bob_key = resp.json()["result"]["user_key"]
# USER cannot register users
resp = await admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={"user_id": "charlie", "role": "user"},
headers={"X-API-Key": bob_key},
)
assert resp.status_code == 403
async def test_no_auth_admin_api_returns_401(admin_client: httpx.AsyncClient):
"""Admin API without key should return 401."""
resp = await admin_client.get("/api/v1/admin/accounts")
assert resp.status_code == 401
# ---- Legacy migration ----
async def test_user_role_cannot_run_legacy_migration(admin_client: httpx.AsyncClient):
"""Legacy migration is ROOT-only."""
acct = _uid()
await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
resp = await admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={"user_id": "bob", "role": "user"},
headers=root_headers(),
)
bob_key = resp.json()["result"]["user_key"]
resp = await admin_client.post(
"/api/v1/admin/migrate",
headers={"X-API-Key": bob_key},
)
assert resp.status_code == 403
async def test_legacy_migration_preflight_failure_does_not_create_task(
admin_client: httpx.AsyncClient,
admin_service: OpenVikingService,
):
"""Ambiguous session ownership fails preflight before task creation."""
acct = _uid()
await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
await admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={"user_id": "bob", "role": "user"},
headers=root_headers(),
)
await _agfs_write(admin_service, f"/local/{acct}/session/orphan/messages.jsonl", "{}\n")
resp = await admin_client.post("/api/v1/admin/migrate", headers=root_headers())
assert resp.status_code == 412
error = resp.json()["error"]
assert error["code"] == "FAILED_PRECONDITION"
assert error["details"]["operation_count"] == 0
assert error["details"]["errors"][0]["session_id"] == "orphan"
tasks_resp = await admin_client.get(
"/api/v1/tasks?task_type=legacy_migration",
headers=root_headers(),
)
assert tasks_resp.status_code == 200
assert tasks_resp.json()["result"] == []
async def test_legacy_migration_only_moves_sessions(
admin_client: httpx.AsyncClient,
admin_app,
admin_service: OpenVikingService,
):
"""Migration preserves public agent content and moves sessions under their owners."""
acct = _uid()
await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
shared_path = f"/local/{acct}/agent/workflows/memories/guide.md"
await _agfs_write(admin_service, shared_path, "shared workflow")
for session_id, owner in (("s1", "alice"), ("s2", "charlie")):
await _agfs_write(
admin_service,
f"/local/{acct}/session/{session_id}/.meta.json",
json.dumps({"created_by_user_id": owner}),
)
await _agfs_write(
admin_service,
f"/local/{acct}/session/{session_id}/messages.jsonl",
'{"role":"user"}\n',
)
resp = await admin_client.post("/api/v1/admin/migrate", headers=root_headers())
assert resp.status_code == 200
task = await _wait_for_task(admin_client, resp.json()["result"]["task_id"])
assert task["status"] == "completed"
result = task["result"]
assert result["migrated"]["operations"] == {"sessions": 2}
assert result["created_users"] == [{"account_id": acct, "user_id": "charlie"}]
assert admin_app.state.api_key_manager.has_user(acct, "charlie")
assert await _agfs_read_text(admin_service, shared_path) == "shared workflow"
for session_id, owner in (("s1", "alice"), ("s2", "charlie")):
assert (
await _agfs_read_text(
admin_service, f"/local/{acct}/user/{owner}/sessions/{session_id}/messages.jsonl"
)
== '{"role":"user"}\n'
)
assert not await _agfs_exists(admin_service, f"/local/{acct}/user/{owner}/peers/workflows")
async def test_legacy_cleanup_preserves_public_agent_directories(
admin_client: httpx.AsyncClient,
admin_service: OpenVikingService,
):
"""Session cleanup leaves public agent directories and user-owned data intact."""
acct = _uid()
await admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=root_headers(),
)
await _agfs_write(admin_service, f"/local/{acct}/session/s1/messages.jsonl", "old session")
preserved_paths = [
f"/local/{acct}/agent/skills/demo/SKILL.md",
f"/local/{acct}/agent/workflows/daily.md",
f"/local/{acct}/user/alice/agent/notes.md",
f"/local/{acct}/user/alice/peers/customer/memories/profile.md",
f"/local/{acct}/user/alice/sessions/s1/messages.jsonl",
]
for path in preserved_paths:
await _agfs_write(admin_service, path, "preserved")
resp = await admin_client.post(
"/api/v1/admin/migrate",
json={"action": "cleanup"},
headers=root_headers(),
)
assert resp.status_code == 200
task = await _wait_for_task(admin_client, resp.json()["result"]["task_id"])
assert task["status"] == "completed"
assert task["task_type"] == "legacy_cleanup"
assert task["result"]["cleanup"]["targets"] == [
{"account_id": acct, "type": "session", "source": "viking://session"}
]
assert not await _agfs_exists(admin_service, f"/local/{acct}/session")
for path in preserved_paths:
assert await _agfs_read_text(admin_service, path) == "preserved"
async def test_session_storage_uses_canonical_uri(
admin_service: OpenVikingService,
):
"""Canonical session URIs can read existing session storage."""
ctx = RequestContext(user=UserIdentifier("default", "admin_user"), role=Role.USER)
await _agfs_write(
admin_service,
"/local/default/session/old-session/messages.jsonl",
'{"role":"user"}\n',
)
await _agfs_write(
admin_service,
"/local/default/session/old-session/.meta.json",
'{"created_by_user_id":"admin_user"}',
)
assert (
await admin_service.viking_fs.read_file(
"viking://user/admin_user/sessions/old-session/messages.jsonl",
ctx=ctx,
)
== '{"role":"user"}\n'
)
@pytest_asyncio.fixture(scope="function")
async def trusted_admin_app(admin_service):
from openviking.server.auth.plugins import TrustedAuthPlugin
from openviking.server.auth.registry import get_registry
config = ServerConfig(auth_mode="trusted", root_api_key=ROOT_KEY)
app = create_app(config=config, service=admin_service)
set_service(admin_service)
manager = APIKeyManager(root_key=ROOT_KEY, viking_fs=admin_service.viking_fs)
await manager.load()
# Create test users for trusted mode tests if they don't exist
if "platform" not in manager._accounts:
await manager.create_account("platform", "gateway-admin")
app.state.api_key_manager = manager
# Set auth plugin (lifespan not triggered in ASGI tests)
registry = get_registry()
if registry.get("trusted") is None:
registry.register(TrustedAuthPlugin)
app.state.auth_plugin = registry.get("trusted")()
return app
@pytest_asyncio.fixture(scope="function")
async def trusted_admin_client(trusted_admin_app):
transport = httpx.ASGITransport(app=trusted_admin_app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c:
yield c
async def test_trusted_mode_root_can_create_account(
trusted_admin_client: httpx.AsyncClient,
trusted_admin_app,
):
"""Trusted ROOT requests should be able to create accounts."""
acct = _uid()
resp = await trusted_admin_client.post(
"/api/v1/admin/accounts",
json={
"account_id": acct,
"admin_user_id": "alice",
},
headers=trusted_headers(
account="platform",
user="gateway-admin",
include_api_key=True,
),
)
assert resp.status_code == 200
body = resp.json()
assert body["result"]["account_id"] == acct
assert body["result"]["admin_user_id"] == "alice"
assert "user_key" not in body["result"]
async def test_trusted_mode_admin_can_register_user_in_own_account(
trusted_admin_client: httpx.AsyncClient,
trusted_admin_app,
):
"""Trusted ADMIN requests should be able to manage users in their own account."""
acct = _uid()
create_resp = await trusted_admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=trusted_headers(
account="platform",
user="gateway-admin",
include_api_key=True,
),
)
assert create_resp.status_code == 200
resp = await trusted_admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={"user_id": "bob", "role": "user"},
headers=trusted_headers(
account=acct,
user="alice",
include_api_key=True,
),
)
assert resp.status_code == 200
assert resp.json()["result"]["account_id"] == acct
assert resp.json()["result"]["user_id"] == "bob"
assert "user_key" not in resp.json()["result"]
async def test_trusted_mode_admin_can_list_users_with_account_only_in_url(
trusted_admin_client: httpx.AsyncClient,
trusted_admin_app,
):
"""Trusted ADMIN requests may omit X-OpenViking-Account when the URL already provides it."""
acct = _uid()
create_resp = await trusted_admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=trusted_headers(
account="platform",
user="gateway-admin",
include_api_key=True,
),
)
assert create_resp.status_code == 200
resp = await trusted_admin_client.get(
f"/api/v1/admin/accounts/{acct}/users",
headers={
"X-API-Key": ROOT_KEY,
"X-OpenViking-User": "alice",
"X-OpenViking-Account": acct,
},
)
assert resp.status_code == 200
assert any(user["user_id"] == "alice" for user in resp.json()["result"])
async def test_trusted_mode_admin_can_list_users_without_account_or_user_headers(
trusted_admin_client: httpx.AsyncClient,
trusted_admin_app,
):
"""Trusted admin routes may omit caller account/user when the route itself identifies the target."""
acct = _uid()
create_resp = await trusted_admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=trusted_headers(
account="platform",
user="gateway-admin",
include_api_key=True,
),
)
assert create_resp.status_code == 200
resp = await trusted_admin_client.get(
f"/api/v1/admin/accounts/{acct}/users",
headers={"X-API-Key": ROOT_KEY},
)
assert resp.status_code == 200
assert any(user["user_id"] == "alice" for user in resp.json()["result"])
async def test_trusted_mode_admin_cannot_register_user_in_other_account(
trusted_admin_client: httpx.AsyncClient,
trusted_admin_app,
):
"""Trusted ADMIN requests should reject conflicting account identity."""
acct = _uid()
other = _uid()
for account_id, admin_user_id in ((acct, "alice"), (other, "eve")):
create_resp = await trusted_admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": account_id, "admin_user_id": admin_user_id},
headers=trusted_headers(
account="platform",
user="gateway-admin",
include_api_key=True,
),
)
assert create_resp.status_code == 200
resp = await trusted_admin_client.post(
f"/api/v1/admin/accounts/{other}/users",
json={"user_id": "bob", "role": "user"},
headers=trusted_headers(
account=acct,
user="alice",
include_api_key=True,
),
)
assert resp.status_code == 400
assert resp.json()["error"]["code"] == "INVALID_ARGUMENT"
async def test_trusted_mode_admin_api_uses_trusted_gateway_identity(
trusted_admin_client: httpx.AsyncClient,
trusted_admin_app,
):
"""Trusted admin routes use the trusted gateway identity instead of tenant user role."""
acct = _uid()
manager = trusted_admin_app.state.api_key_manager
create_resp = await trusted_admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "alice"},
headers=trusted_headers(
account="platform",
user="gateway-admin",
include_api_key=True,
),
)
assert create_resp.status_code == 200
# Change alice to USER role
await manager.set_role(acct, "alice", "user")
resp = await trusted_admin_client.post(
f"/api/v1/admin/accounts/{acct}/users",
json={"user_id": "bob", "role": "user"},
headers=trusted_headers(
account=acct,
user="alice",
include_api_key=True,
),
)
assert resp.status_code == 200
assert resp.json()["result"]["account_id"] == acct
assert resp.json()["result"]["user_id"] == "bob"
assert "user_key" not in resp.json()["result"]
async def test_trusted_mode_requires_matching_api_key_for_admin_api(
trusted_admin_client: httpx.AsyncClient,
trusted_admin_app,
):
"""Trusted admin requests should require the configured server API key when present."""
resp = await trusted_admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": _uid(), "admin_user_id": "alice"},
headers=trusted_headers(
account="platform",
user="gateway-admin",
include_api_key=False,
),
)
assert resp.status_code == 401
async def test_trusted_mode_create_account_lists_current_account_metadata(
trusted_admin_client: httpx.AsyncClient,
trusted_admin_app,
):
"""Trusted account creation should list the current account metadata shape."""
acct = _uid()
resp = await trusted_admin_client.post(
"/api/v1/admin/accounts",
json={
"account_id": acct,
"admin_user_id": "alice",
},
headers=trusted_headers(
account="platform",
user="gateway-admin",
include_api_key=True,
),
)
assert resp.status_code == 200
manager = trusted_admin_app.state.api_key_manager
account = next(item for item in manager.get_accounts() if item["account_id"] == acct)
assert set(account) == {"account_id", "created_at", "user_count", "status"}
assert account["status"] == "active"
async def test_user_page_summary_and_search_preserve_legacy_response(
lightweight_admin_client: httpx.AsyncClient,
lightweight_admin_app: FastAPI,
):
acct = _uid()
created = await lightweight_admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "owner"},
headers=root_headers(),
)
assert created.status_code == 200
manager = lightweight_admin_app.state.api_key_manager
seed = {
f"user-{index}": {
"role": "admin" if index == 2832 else "user",
"key": f"test-key-{index}",
}
for index in range(1, 2833)
}
seed["deleting-user"] = {"role": "admin", "key": "deleted", "deletion": {"status": "pending"}}
await manager._legacy._save_users_json(acct, seed)
url = f"/api/v1/admin/accounts/{acct}/users"
response = await lightweight_admin_client.get(
url, params={"include_summary": True, "limit": 20, "page": 142}, headers=root_headers()
)
assert response.status_code == 200, response.text
result = response.json()["result"]
assert len(result["users"]) == 13
assert result["users"][-1]["user_id"] == "user-2832"
assert result["total"] == result["account_total"] == result["key_count"] == 2833
assert result["manager_count"] == 2
response = await lightweight_admin_client.get(
url,
params={"include_summary": True, "limit": 20, "query": " UsEr-2832 "},
headers=root_headers(),
)
result = response.json()["result"]
assert result["total"] == 1
assert result["users"][0]["user_id"] == "user-2832"
assert result["account_total"] == 2833
assert result["manager_count"] == 2
response = await lightweight_admin_client.get(
url,
params={"include_summary": True, "limit": 20, "query": "missing"},
headers=root_headers(),
)
assert response.json()["result"]["total"] == 0
assert response.json()["result"]["users"] == []
assert response.json()["result"]["account_total"] == 2833
response = await lightweight_admin_client.get(url, headers=root_headers())
assert isinstance(response.json()["result"], list)
assert len(response.json()["result"]) == 2833
response = await lightweight_admin_client.get(
url,
params={"limit": 1, "page": 2, "name": "user-*", "role": "user"},
headers=root_headers(),
)
assert response.json()["result"][0]["user_id"] == "user-2"
hidden = manager.get_users_page(acct, expose_key=False, limit=1)
assert hidden["key_count"] == 0
assert "api_key" not in hidden["users"][0]
assert "key_prefix" not in hidden["users"][0]
async def test_user_page_summary_respects_account_access(lightweight_admin_client):
acct = _uid()
response = await lightweight_admin_client.post(
"/api/v1/admin/accounts",
json={"account_id": acct, "admin_user_id": "owner"},
headers=root_headers(),
)
admin_key = response.json()["result"]["user_key"]
own = await lightweight_admin_client.get(
f"/api/v1/admin/accounts/{acct}/users?include_summary=true&limit=20",
headers={"X-API-Key": admin_key},
)
assert own.status_code == 200
assert own.json()["result"]["account_total"] == 1
denied = await lightweight_admin_client.get(
"/api/v1/admin/accounts/default/users?include_summary=true&limit=20",
headers={"X-API-Key": admin_key},
)
assert denied.status_code == 403