feat(manifest): add bottle.tokens with TokenEntry (PRD 0010)
TokenEntry carries Kind (anthropic / github / gitea / npm), TokenRef (name of host env var the CLI resolves at launch), and an optional Url (required for gitea, fixed for the other kinds). Validation rejects unknown kinds, duplicate non-gitea entries, duplicate gitea Urls, and overlap with bottle.git hosts (where git-gate is already brokering). No wiring yet — the field exists on Bottle but cred-proxy is the next step. Adds tests/unit/test_manifest_tokens.py.
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
"""Unit: Bottle.tokens manifest parsing + validation (PRD 0010)."""
|
||||
|
||||
import unittest
|
||||
|
||||
from claude_bottle.log import Die
|
||||
from claude_bottle.manifest import Manifest
|
||||
|
||||
|
||||
def _manifest(tokens, git=None):
|
||||
bottle: dict[str, object] = {"tokens": tokens}
|
||||
if git is not None:
|
||||
bottle["git"] = git
|
||||
return {
|
||||
"bottles": {"dev": bottle},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
}
|
||||
|
||||
|
||||
class TestTokenEntryParsing(unittest.TestCase):
|
||||
def test_parses_anthropic_entry(self):
|
||||
m = Manifest.from_json_obj(_manifest([
|
||||
{"Kind": "anthropic", "TokenRef": "CLAUDE_BOTTLE_OAUTH_TOKEN"},
|
||||
]))
|
||||
entries = m.bottles["dev"].tokens
|
||||
self.assertEqual(1, len(entries))
|
||||
e = entries[0]
|
||||
self.assertEqual("anthropic", e.Kind)
|
||||
self.assertEqual("CLAUDE_BOTTLE_OAUTH_TOKEN", e.TokenRef)
|
||||
self.assertEqual("", e.Url)
|
||||
self.assertEqual("api.anthropic.com", e.UpstreamHost)
|
||||
|
||||
def test_parses_github_entry(self):
|
||||
m = Manifest.from_json_obj(_manifest([
|
||||
{"Kind": "github", "TokenRef": "GITHUB_TOKEN"},
|
||||
]))
|
||||
e = m.bottles["dev"].tokens[0]
|
||||
self.assertEqual("github", e.Kind)
|
||||
self.assertEqual("github.com", e.UpstreamHost)
|
||||
|
||||
def test_parses_npm_entry(self):
|
||||
m = Manifest.from_json_obj(_manifest([
|
||||
{"Kind": "npm", "TokenRef": "NPM_TOKEN"},
|
||||
]))
|
||||
e = m.bottles["dev"].tokens[0]
|
||||
self.assertEqual("registry.npmjs.org", e.UpstreamHost)
|
||||
|
||||
def test_parses_gitea_entry_with_url(self):
|
||||
m = Manifest.from_json_obj(_manifest([
|
||||
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN",
|
||||
"Url": "https://gitea.dideric.is"},
|
||||
]))
|
||||
e = m.bottles["dev"].tokens[0]
|
||||
self.assertEqual("gitea", e.Kind)
|
||||
self.assertEqual("https://gitea.dideric.is", e.Url)
|
||||
self.assertEqual("gitea.dideric.is", e.UpstreamHost)
|
||||
|
||||
def test_gitea_url_with_port_strips_port_from_host(self):
|
||||
m = Manifest.from_json_obj(_manifest([
|
||||
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN",
|
||||
"Url": "https://gitea.dideric.is:30009"},
|
||||
]))
|
||||
self.assertEqual("gitea.dideric.is", m.bottles["dev"].tokens[0].UpstreamHost)
|
||||
|
||||
|
||||
class TestTokenEntryValidation(unittest.TestCase):
|
||||
def test_unknown_kind_dies(self):
|
||||
with self.assertRaises(Die):
|
||||
Manifest.from_json_obj(_manifest([
|
||||
{"Kind": "aws", "TokenRef": "AWS_TOKEN"},
|
||||
]))
|
||||
|
||||
def test_missing_kind_dies(self):
|
||||
with self.assertRaises(Die):
|
||||
Manifest.from_json_obj(_manifest([
|
||||
{"TokenRef": "GITHUB_TOKEN"},
|
||||
]))
|
||||
|
||||
def test_missing_token_ref_dies(self):
|
||||
with self.assertRaises(Die):
|
||||
Manifest.from_json_obj(_manifest([
|
||||
{"Kind": "github"},
|
||||
]))
|
||||
|
||||
def test_gitea_without_url_dies(self):
|
||||
with self.assertRaises(Die):
|
||||
Manifest.from_json_obj(_manifest([
|
||||
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN"},
|
||||
]))
|
||||
|
||||
def test_gitea_with_non_https_url_dies(self):
|
||||
with self.assertRaises(Die):
|
||||
Manifest.from_json_obj(_manifest([
|
||||
{"Kind": "gitea", "TokenRef": "GITEA_TOKEN",
|
||||
"Url": "http://gitea.dideric.is"},
|
||||
]))
|
||||
|
||||
def test_non_gitea_kind_with_url_dies(self):
|
||||
# Url is fixed for anthropic / github / npm — passing one is a
|
||||
# configuration smell, not an override knob.
|
||||
with self.assertRaises(Die):
|
||||
Manifest.from_json_obj(_manifest([
|
||||
{"Kind": "github", "TokenRef": "GITHUB_TOKEN",
|
||||
"Url": "https://api.example.com"},
|
||||
]))
|
||||
|
||||
def test_duplicate_non_gitea_kind_dies(self):
|
||||
with self.assertRaises(Die):
|
||||
Manifest.from_json_obj(_manifest([
|
||||
{"Kind": "github", "TokenRef": "A"},
|
||||
{"Kind": "github", "TokenRef": "B"},
|
||||
]))
|
||||
|
||||
def test_two_gitea_with_distinct_urls_ok(self):
|
||||
m = Manifest.from_json_obj(_manifest([
|
||||
{"Kind": "gitea", "TokenRef": "T1",
|
||||
"Url": "https://gitea.dideric.is"},
|
||||
{"Kind": "gitea", "TokenRef": "T2",
|
||||
"Url": "https://gitea.example.com"},
|
||||
]))
|
||||
self.assertEqual(2, len(m.bottles["dev"].tokens))
|
||||
|
||||
def test_two_gitea_with_same_url_dies(self):
|
||||
with self.assertRaises(Die):
|
||||
Manifest.from_json_obj(_manifest([
|
||||
{"Kind": "gitea", "TokenRef": "T1",
|
||||
"Url": "https://gitea.dideric.is"},
|
||||
{"Kind": "gitea", "TokenRef": "T2",
|
||||
"Url": "https://gitea.dideric.is"},
|
||||
]))
|
||||
|
||||
|
||||
class TestTokenGitOverlap(unittest.TestCase):
|
||||
def test_github_token_collides_with_github_git_entry(self):
|
||||
# bottle.git already brokers github.com via the gate; declaring
|
||||
# a github token on top would put two credential brokers on
|
||||
# the same remote.
|
||||
with self.assertRaises(Die):
|
||||
Manifest.from_json_obj(_manifest(
|
||||
tokens=[{"Kind": "github", "TokenRef": "GITHUB_TOKEN"}],
|
||||
git=[{
|
||||
"Name": "myrepo",
|
||||
"Upstream": "ssh://git@github.com/me/myrepo.git",
|
||||
"IdentityFile": "/dev/null",
|
||||
}],
|
||||
))
|
||||
|
||||
def test_gitea_token_collides_with_same_host_git_entry(self):
|
||||
with self.assertRaises(Die):
|
||||
Manifest.from_json_obj(_manifest(
|
||||
tokens=[{
|
||||
"Kind": "gitea", "TokenRef": "GITEA_TOKEN",
|
||||
"Url": "https://gitea.dideric.is",
|
||||
}],
|
||||
git=[{
|
||||
"Name": "myrepo",
|
||||
"Upstream": "ssh://git@gitea.dideric.is:30009/me/myrepo.git",
|
||||
"IdentityFile": "/dev/null",
|
||||
}],
|
||||
))
|
||||
|
||||
def test_anthropic_token_does_not_collide_with_git(self):
|
||||
# api.anthropic.com isn't a git host; no overlap possible.
|
||||
m = Manifest.from_json_obj(_manifest(
|
||||
tokens=[{"Kind": "anthropic", "TokenRef": "CLAUDE_BOTTLE_OAUTH_TOKEN"}],
|
||||
git=[{
|
||||
"Name": "myrepo",
|
||||
"Upstream": "ssh://git@gitea.dideric.is:30009/me/myrepo.git",
|
||||
"IdentityFile": "/dev/null",
|
||||
}],
|
||||
))
|
||||
self.assertEqual(1, len(m.bottles["dev"].tokens))
|
||||
|
||||
|
||||
class TestEmptyTokensField(unittest.TestCase):
|
||||
def test_no_tokens_field_yields_empty_tuple(self):
|
||||
m = Manifest.from_json_obj({
|
||||
"bottles": {"dev": {}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
self.assertEqual((), m.bottles["dev"].tokens)
|
||||
|
||||
def test_tokens_array_type_required(self):
|
||||
with self.assertRaises(Die):
|
||||
Manifest.from_json_obj({
|
||||
"bottles": {"dev": {"tokens": "not-a-list"}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user