"""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()