File size: 8,500 Bytes
9b403c4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | """只用HTTP fixture验证密钥生命周期,不连接真实Supabase或生成实际账号。"""
from __future__ import annotations
import hashlib
import json
import sys
import unittest
import uuid
from pathlib import Path
import httpx
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "apps/market-api"))
from app.native.auth_keys import KeyStore, KeyStoreError
class KeyStoreTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.requests = []
self.rows = {}
self.failure = None
self.audit_rows = []
self.client = httpx.AsyncClient(transport=httpx.MockTransport(self.handle),
headers={"Authorization": "Bearer inherited-fixture"},
cookies={"session": "inherited-fixture-cookie"},
auth=httpx.BasicAuth("fixture", "fixture-password"))
self.store = KeyStore(self.client, "https://fixture.supabase.co", "sb_publishable_fixture")
async def asyncTearDown(self):
await self.client.aclose()
def handle(self, request):
self.requests.append(request)
if self.failure is not None:
return httpx.Response(self.failure, text="fixture-private-upstream-error")
payload = json.loads(request.content)
name = request.url.path.split("/")[-1]
if name == "message_create_api_key":
self.rows[payload["p_id"]] = {
"id": payload["p_id"], "profile_id": payload["p_profile_id"],
"name": payload["p_name"], "prefix": payload["p_prefix"],
"key_hash": payload["p_key_hash"], "created_at": 1, "expires_at": 9999999999,
"last_used_at": None, "revoked_at": None, "created_by": "fixture-admin",
}
return httpx.Response(200, json=self.rows[payload["p_id"]])
if name == "message_list_api_keys":
return httpx.Response(200, json=[r for r in self.rows.values() if r["profile_id"] == payload["p_profile_id"]])
if name == "message_revoke_api_key":
row = self.rows.get(payload["p_id"])
found = bool(row and row["profile_id"] == payload["p_profile_id"] and row["revoked_at"] is None)
if found:
row["revoked_at"] = 2
return httpx.Response(200, json=found)
if name == "message_validate_api_key":
found = next((r for r in self.rows.values() if r["profile_id"] == payload["p_profile_id"]
and r["key_hash"] == payload["p_key_hash"] and r["revoked_at"] is None), None)
return httpx.Response(200, json={"id": found["id"], "profile_id": found["profile_id"]}) if found else httpx.Response(200, text="null")
if name == "message_auth_audit":
return httpx.Response(200, json=self.audit_rows)
raise AssertionError(name)
async def test_key_secret_is_returned_once_and_only_hash_crosses_rpc(self):
created = await self.store.create("alpha", "编辑器", 30, "fixture-jwt")
self.assertRegex(created["key"], r"^msg_key\.[a-f0-9]{32}\.[A-Za-z0-9_-]{43}$")
request = self.requests[-1]
payload = json.loads(request.content)
self.assertNotIn(created["key"], request.content.decode())
self.assertEqual(payload["p_key_hash"], hashlib.sha256(created["key"].encode()).hexdigest())
self.assertEqual(request.headers["apikey"], "sb_publishable_fixture")
self.assertEqual(request.headers["Authorization"], "Bearer fixture-jwt")
self.assertNotIn("Cookie", request.headers)
self.assertNotIn("key_hash", created)
listed = await self.store.list("alpha", "fixture-jwt")
self.assertEqual(listed, [{k: v for k, v in created.items() if k != "key"}])
self.assertNotIn("key", listed[0])
self.assertNotIn("key_hash", listed[0])
async def test_key_validation_is_profile_bound_and_anonymous(self):
created = await self.store.create("alpha", "fixture", 1, "fixture-jwt")
identity = await self.store.authenticate(created["key"], "alpha")
self.assertEqual(identity, {"id": created["id"], "profile_id": "alpha"})
self.assertNotIn("Authorization", self.requests[-1].headers)
self.assertNotIn("Cookie", self.requests[-1].headers)
self.assertNotIn(created["key"], self.requests[-1].content.decode())
self.assertIsNone(await self.store.authenticate(created["key"], "beta"))
bad = created["key"][:-1] + ("A" if created["key"][-1] != "A" else "B")
self.assertIsNone(await self.store.authenticate(bad, "alpha"))
async def test_revoke_does_not_cross_profiles_and_immediately_rejects_key(self):
created = await self.store.create("alpha", "fixture", 365, "fixture-jwt")
self.assertFalse(await self.store.revoke("beta", created["id"], "fixture-jwt"))
self.assertTrue(await self.store.revoke("alpha", created["id"], "fixture-jwt"))
self.assertIsNone(await self.store.authenticate(created["key"], "alpha"))
self.assertFalse(await self.store.revoke("alpha", created["id"], "fixture-jwt"))
self.assertIsNotNone((await self.store.list("alpha", "fixture-jwt"))[0]["revoked_at"])
async def test_invalid_creation_inputs_never_reach_network(self):
for days in (0, -1, 366, True, 2.5, "30"):
with self.subTest(days=days), self.assertRaises(KeyStoreError) as caught:
await self.store.create("alpha", "fixture", days, "fixture-jwt")
self.assertEqual(caught.exception.status_code, 422)
for name in ("", " \n", "x" * 81, "key\x00name"):
with self.subTest(name=name), self.assertRaises(KeyStoreError):
await self.store.create("alpha", name, 1, "fixture-jwt")
self.assertEqual(self.requests, [])
async def test_malformed_credentials_never_reach_network(self):
for key in ("", "fixture-jwt", "msg_key." + "x" * 2000, None):
self.assertIsNone(await self.store.authenticate(key, "alpha"))
with self.assertRaises(KeyStoreError):
await self.store.revoke("alpha", "not-a-uuid", "fixture-jwt")
with self.assertRaises(KeyStoreError):
await self.store.list("../other", "fixture-jwt")
with self.assertRaises(KeyStoreError):
await self.store.list("alpha", "")
self.assertEqual(self.requests, [])
async def test_upstream_errors_are_safe_and_never_accepted(self):
for status, expected in ((401, 401), (403, 403), (400, 422), (409, 409), (500, 503), (302, 503)):
self.failure = status
with self.subTest(status=status), self.assertRaises(KeyStoreError) as caught:
await self.store.list("alpha", "fixture-jwt")
self.assertEqual(caught.exception.status_code, expected)
self.assertNotIn("fixture-private", str(caught.exception))
async def test_audit_only_returns_known_safe_fields(self):
row = {"id": str(uuid.uuid4()), "event": "key.created", "created_at": 1,
"actor": "fixture-admin", "profile_id": "alpha", "detail": "已创建客户端密钥"}
self.audit_rows = [{**row, "key_hash": "unexpected-private-value"}]
self.assertEqual(await self.store.audit("fixture-jwt"), [row])
async def test_malformed_upstream_and_transport_failure_are_unavailable(self):
async def invalid(request):
return httpx.Response(200, text="not-json")
async with httpx.AsyncClient(transport=httpx.MockTransport(invalid)) as client:
store = KeyStore(client, "https://fixture.supabase.co", "sb_publishable_fixture")
with self.assertRaises(KeyStoreError) as caught:
await store.list("alpha", "fixture-jwt")
self.assertEqual(caught.exception.status_code, 503)
async def failing(request):
raise httpx.ConnectError("fixture-network-detail")
async with httpx.AsyncClient(transport=httpx.MockTransport(failing)) as client:
store = KeyStore(client, "https://fixture.supabase.co", "sb_publishable_fixture")
with self.assertRaises(KeyStoreError) as caught:
await store.list("alpha", "fixture-jwt")
self.assertEqual(caught.exception.status_code, 503)
self.assertNotIn("fixture-network-detail", str(caught.exception))
if __name__ == "__main__":
unittest.main()
|