Files
OpenViking/tests/unit/test_uri_short_format.py
t0saki ff38bb5dd1 feat(uri): add viking://~ home alias for the caller's user root (#4167)
* feat(uri): add viking://~ home alias for the caller's user root

Accept `viking://~` (and `viking://~/<suffix>`) as a server-side alias
for the authenticated caller's user namespace root. The alias is
expanded at the request boundary by resolve_current_user_uri for
USER/ADMIN identities, so every control plane that already funnels
through validate_request_viking_uri (REST, MCP, and therefore CLI/SDK
clients) gets it with no client changes.

Design points:
- `~` is a reserved token that can never collide with a real user id
  (validate_user_id's charset excludes it), so segment-0 aliasing does
  not weaken canonical-first parsing.
- The canonical parser (resolve_uri) rejects the alias outright,
  mirroring the legacy-session handling: root-role requests, internal
  callers, and storage paths fail closed instead of materializing a
  literal '~' directory.
- The alias is accepted but never advertised: scope error copy
  ("Must be one of: ...") filters it at both the parser and the public
  validator, and VikingURI.build refuses to mint it, so responses and
  persisted data stay canonical.
- MCP search now resolves exclude_uris with the same strictness as the
  REST search router (closes the one entry point that skipped it).

* refactor(mcp): drop tilde mention from tool docstrings

Agents pass viking://~ through verbatim and responses echo the
canonical form, so the alias is self-explanatory on contact; carrying
the sentence in 12 tool descriptions costs every MCP session tokens.
Docs keep the alias documented for humans.
2026-08-20 20:44:40 +08:00

93 lines
3.3 KiB
Python

# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
# SPDX-License-Identifier: AGPL-3.0
"""Tests for the explicit Viking URI contract."""
import pytest
from openviking_cli.utils.uri import VikingURI
class TestVikingURIExplicitFormat:
"""VikingURI only accepts explicit ``viking://`` values."""
@pytest.mark.parametrize(
"value",
[
"/resources",
"resources",
"/user/memories/preferences",
"user/skills/pdf",
"/",
],
)
def test_path_input_is_rejected(self, value: str):
with pytest.raises(ValueError, match="must start with 'viking://'"):
VikingURI(value)
def test_full_format_unchanged(self):
uri = VikingURI("viking://resources/my_project")
assert uri.uri == "viking://resources/my_project"
def test_full_root(self):
uri = VikingURI("viking://")
assert uri.scope == ""
def test_join(self):
joined = VikingURI("viking://resources").join("my_project")
assert joined.uri == "viking://resources/my_project"
def test_parent(self):
parent = VikingURI("viking://user/alice/memories/preferences").parent
assert parent is not None
assert parent.uri == "viking://user/alice/memories"
def test_is_valid_rejects_path_input(self):
assert not VikingURI.is_valid("/resources")
assert not VikingURI.is_valid("user/memories")
def test_invalid_scope_still_rejected(self):
with pytest.raises(ValueError, match="Invalid scope"):
VikingURI("viking://invalid_scope/foo")
def test_home_alias_is_parsable(self):
"""'viking://~' should parse with the home alias scope."""
uri = VikingURI("viking://~")
assert uri.uri == "viking://~"
assert uri.scope == "~"
def test_home_alias_with_suffix_is_parsable(self):
"""'viking://~/notes' keeps the alias scope and its suffix."""
uri = VikingURI("viking://~/notes")
assert uri.uri == "viking://~/notes"
assert uri.scope == "~"
assert uri.full_path == "~/notes"
def test_invalid_scope_error_copy_hides_home_alias(self):
"""The parser's own scope enumeration never advertises the alias."""
with pytest.raises(ValueError, match="Invalid scope") as exc_info:
VikingURI("viking://invalid_scope/foo")
message = str(exc_info.value)
assert "Must be one of:" in message
assert "~" not in message.split("Must be one of:", 1)[1]
def test_build_rejects_home_alias_scope(self):
"""build() must never mint an alias URI: responses stay canonical."""
with pytest.raises(ValueError, match="Invalid scope"):
VikingURI.build("~")
with pytest.raises(ValueError, match="Invalid scope"):
VikingURI.build("~", "notes")
assert VikingURI.build("user", "alice") == "viking://user/alice"
@pytest.mark.parametrize(
"value,scope",
[
("viking://resources", "resources"),
("viking://user", "user"),
("viking://session/abc123", "session"),
("viking://queue", "queue"),
("viking://temp", "temp"),
],
)
def test_explicit_scopes(self, value: str, scope: str):
assert VikingURI(value).scope == scope