Files
codex-app-server/cloud/tests/test_api_keys.py
T

236 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""API keys 多 key 单测:primary + 附加 key、撤销、revoke-others、
登录签发附加 key(不再轮换)。
测试里出现的 key 一律是测试内生成的假 key,绝无真实 secret。
"""
import hashlib
import http.client
import json
import os
import sys
import tempfile
import threading
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.oauth import map_external_identity_to_local_user_with_key
from cloud.server import create_server
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.oauth import map_external_identity_to_local_user_with_key
from cloud.server import create_server
class ApiKeysFunctionTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.user_id, self.primary = self.auth.create_user("user-a")
def tearDown(self):
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def test_primary_listed_as_legacy(self):
keys = self.auth.list_api_keys(self.user_id)
self.assertEqual(len(keys), 1)
self.assertEqual(keys[0]["id"], "legacy")
self.assertEqual(keys[0]["label"], "primary")
self.assertIsNone(keys[0]["hint"])
self.assertIsNone(keys[0]["revoked_at"])
# 列表里绝无明文与哈希。
for key in keys:
self.assertNotIn("api_key", key)
self.assertNotIn("key_hash", key)
def test_additional_key_authenticates_and_revokes(self):
key_id, plain = self.auth.create_api_key(self.user_id, "laptop")
self.assertEqual(self.auth.authenticate(plain), self.user_id)
self.assertEqual(plain[-4:], [
k for k in self.auth.list_api_keys(self.user_id)
if k["id"] == key_id][0]["hint"])
self.assertTrue(self.auth.revoke_api_key(self.user_id, key_id))
self.assertIsNone(self.auth.authenticate(plain))
self.assertEqual(self.auth.authenticate(self.primary), self.user_id)
self.assertFalse(self.auth.revoke_api_key(self.user_id, key_id))
def test_revoke_legacy_keeps_additional(self):
_key_id, plain = self.auth.create_api_key(self.user_id, "ci")
self.assertTrue(self.auth.revoke_api_key(self.user_id, "legacy"))
self.assertIsNone(self.auth.authenticate(self.primary))
self.assertEqual(self.auth.authenticate(plain), self.user_id)
self.assertFalse(self.auth.revoke_api_key(self.user_id, "legacy"))
def test_revoke_others_keeps_only_current(self):
_id1, plain1 = self.auth.create_api_key(self.user_id, "a")
_id2, plain2 = self.auth.create_api_key(self.user_id, "b")
keep = hashlib.sha256(plain2.encode()).hexdigest()
count = self.auth.revoke_other_api_keys(self.user_id, keep)
self.assertEqual(count, 2) # primary + 第一个附加 key
self.assertIsNone(self.auth.authenticate(self.primary))
self.assertIsNone(self.auth.authenticate(plain1))
self.assertEqual(self.auth.authenticate(plain2), self.user_id)
def test_last_used_throttled(self):
key_id, plain = self.auth.create_api_key(self.user_id, "x")
self.assertEqual(self.auth.authenticate(plain), self.user_id)
first = [k for k in self.auth.list_api_keys(self.user_id)
if k["id"] == key_id][0]["last_used_at"]
self.assertIsNotNone(first)
self.assertEqual(self.auth.authenticate(plain), self.user_id)
second = [k for k in self.auth.list_api_keys(self.user_id)
if k["id"] == key_id][0]["last_used_at"]
self.assertEqual(first, second) # 一小时内不重复写库
def test_create_for_unknown_user(self):
with self.assertRaises(ValueError):
self.auth.create_api_key("no-such-user", "x")
def test_oauth_relogin_issues_additional_not_rotation(self):
# 已存在 oauth 用户再次登录:签发附加 key,旧 primary 不死。
user_id, primary = self.auth.create_user("oauth:github:gh-user")
_uid, created, plain, rotated = \
map_external_identity_to_local_user_with_key(
self.auth, "github", {"login": "gh-user",
"external_id": "4242"})
self.assertEqual(_uid, user_id)
self.assertFalse(created)
self.assertFalse(rotated)
self.assertNotEqual(plain, primary)
self.assertEqual(self.auth.authenticate(primary), user_id)
self.assertEqual(self.auth.authenticate(plain), user_id)
class ApiKeysHttpTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.user_a, self.key_a = self.auth.create_user("user-a")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
try:
headers = {}
payload = None
if body is not None:
payload = json.dumps(body)
headers["Content-Type"] = "application/json"
if key is not None:
headers["X-API-Key"] = key
conn.request(method, path, body=payload, headers=headers)
resp = conn.getresponse()
raw = resp.read().decode()
try:
parsed = json.loads(raw)
except ValueError:
parsed = raw
return resp.status, parsed
finally:
conn.close()
def test_keys_require_auth(self):
status, _ = self._req("GET", "/users/me/keys")
self.assertEqual(status, 401)
status, _ = self._req("POST", "/users/me/keys", {})
self.assertEqual(status, 401)
def test_create_list_revoke_flow(self):
status, body = self._req("GET", "/users/me/keys", key=self.key_a)
self.assertEqual(status, 200, body)
self.assertEqual(len(body["keys"]), 1)
self.assertEqual(body["keys"][0]["id"], "legacy")
# 创建附加 key:明文只出现一次,hint 为后 4 位。
status, body = self._req(
"POST", "/users/me/keys", {"label": "laptop"}, key=self.key_a)
self.assertEqual(status, 201, body)
new_key = body["api_key"]
self.assertEqual(body["hint"], new_key[-4:])
key_id = body["id"]
# 新 key 可用;列表里有两条且无明文。
status, _ = self._req("GET", "/users/me/keys", key=new_key)
self.assertEqual(status, 200)
status, body = self._req("GET", "/users/me/keys", key=self.key_a)
self.assertEqual(len(body["keys"]), 2)
self.assertNotIn(new_key, json.dumps(body))
# 撤销附加 key:新 key 401,primary 仍可用;再撤 404。
status, body = self._req(
"DELETE", "/users/me/keys/" + key_id, key=self.key_a)
self.assertEqual(status, 200, body)
status, _ = self._req("GET", "/users/me/keys", key=new_key)
self.assertEqual(status, 401)
status, _ = self._req("GET", "/users/me/keys", key=self.key_a)
self.assertEqual(status, 200)
status, _ = self._req(
"DELETE", "/users/me/keys/" + key_id, key=self.key_a)
self.assertEqual(status, 404)
# 撤销 primary(legacy):primary 401;再撤需用另一把 key,
# 结果应为 404(已撤销),且另一把 key 不受影响。
status, body = self._req(
"POST", "/users/me/keys", {"label": "spare"}, key=self.key_a)
self.assertEqual(status, 201, body)
spare_key = body["api_key"]
status, body = self._req(
"DELETE", "/users/me/keys/legacy", key=self.key_a)
self.assertEqual(status, 200, body)
status, _ = self._req("GET", "/users/me/keys", key=self.key_a)
self.assertEqual(status, 401)
status, _ = self._req(
"DELETE", "/users/me/keys/legacy", key=spare_key)
self.assertEqual(status, 404)
status, _ = self._req("GET", "/users/me/keys", key=spare_key)
self.assertEqual(status, 200)
def test_revoke_legacy_and_revoke_others(self):
_s, created = self._req(
"POST", "/users/me/keys", {"label": "ci"}, key=self.key_a)
ci_key = created["api_key"]
_s, created2 = self._req(
"POST", "/users/me/keys", {"label": "home"}, key=self.key_a)
home_key = created2["api_key"]
# revoke-others(用 home key 调):primary + ci 被撤销。
status, body = self._req(
"POST", "/users/me/keys/revoke-others", {}, key=home_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["revoked_count"], 2)
status, _ = self._req("GET", "/users/me/keys", key=self.key_a)
self.assertEqual(status, 401)
status, _ = self._req("GET", "/users/me/keys", key=ci_key)
self.assertEqual(status, 401)
status, _ = self._req("GET", "/users/me/keys", key=home_key)
self.assertEqual(status, 200)
# primary 已被 revoke-others 撤销,再撤 legacy 应为 404;
# home key 本身不受影响。
status, body = self._req(
"DELETE", "/users/me/keys/legacy", key=home_key)
self.assertEqual(status, 404, body)
status, _ = self._req("GET", "/users/me/keys", key=home_key)
self.assertEqual(status, 200)
if __name__ == "__main__":
unittest.main()