fix: preserve selected identity in streamed health exports

This commit is contained in:
weiyuqing committed 2026-10-17 14:17:35 +08:00
1 parent bcd4cd3759
commit 6292755ff9
4 files changed
+121 -11

No files matched your search

+14 -3
View File
@@ -5,13 +5,13 @@ const fs=require('node:fs');
const HealthViews=require('../src/mi_fitness_mcp/web_assets/static/js/health-views.js'); const HealthViews=require('../src/mi_fitness_mcp/web_assets/static/js/health-views.js');
function summary(kind='stress',number=1,total=21){return {status:'ok',kind,range:{start:'2026-10-15',end:'2026-10-17'},statistics:{count:total,primary_mean:33,primary_min:10,primary_max:60},coverage:1,stride:1,points:[{date:'2026-10-16',values:[33],missing:false}],page:{number,size:10,total,rows:[{timestamp:'2026-10-16T00:00:00Z',stress_score:number}]}};} function summary(kind='stress',number=1,total=21){return {status:'ok',kind,range:{start:'2026-10-15',end:'2026-10-17'},statistics:{count:total,primary_mean:33,primary_min:10,primary_max:60},coverage:1,stride:1,points:[{date:'2026-10-16',values:[33],missing:false}],page:{number,size:10,total,rows:[{timestamp:'2026-10-16T00:00:00Z',stress_score:number}]}};}
function fixture(){ function fixture(){
const nodes=new Map(),links=[],requests=[],calendars=[]; const nodes=new Map(),links=[],forms=[],elements=[],requests=[],calendars=[];
const get=id=>{if(!nodes.has(id))nodes.set(id,{value:({startDate:'2026-10-15',endDate:'2026-10-17',napsSelect:'true'})[id]||'',textContent:'',innerHTML:'',checked:false,setAttribute(){},classList:{toggle(){},add(){},remove(){}},querySelector(){return get(id+'-table');}});return nodes.get(id);}; const get=id=>{if(!nodes.has(id))nodes.set(id,{value:({startDate:'2026-10-15',endDate:'2026-10-17',napsSelect:'true'})[id]||'',textContent:'',innerHTML:'',checked:false,setAttribute(){},classList:{toggle(){},add(){},remove(){}},querySelector(){return get(id+'-table');}});return nodes.get(id);};
const context=vm.createContext({HealthViews,AbortController,console,performance,URL,document:{getElementById:get,querySelectorAll(){return [];},addEventListener(){},createElement(){const a={click(){links.push(this.href);},remove(){}};return a;},body:{appendChild(){}}},localStorage:{getItem(){return '';},setItem(){}},setTimeout(){},setInterval(){},clearTimeout(){},clearInterval(){},fetch:url=>new Promise(resolve=>{if(url.startsWith('/api/calendar'))calendars.push({url,resolve});else requests.push({url,resolve});})}); const context=vm.createContext({HealthViews,AbortController,console,performance,URL,document:{getElementById:get,querySelectorAll(){return [];},addEventListener(){},createElement(tag){const element={tag,children:[],appendChild(child){this.children.push(child);},click(){links.push(this.href);},submit(){forms.push({method:this.method,action:this.action,fields:this.children.map(child=>({name:child.name,value:child.value,type:child.type}))});},remove(){this.removed=true;}};elements.push(element);return element;},body:{appendChild(){}}},localStorage:{getItem(){return '';},setItem(){}},setTimeout(){},setInterval(){},clearTimeout(){},clearInterval(){},fetch:url=>new Promise(resolve=>{if(url.startsWith('/api/calendar'))calendars.push({url,resolve});else requests.push({url,resolve});})});
for(const name of ['health-ui','app'])vm.runInContext(fs.readFileSync(`src/mi_fitness_mcp/web_assets/static/js/${name}.js`,'utf8'),context); for(const name of ['health-ui','app'])vm.runInContext(fs.readFileSync(`src/mi_fitness_mcp/web_assets/static/js/${name}.js`,'utf8'),context);
vm.runInContext('showToast=()=>{};state.activeTab="stress";',context); vm.runInContext('showToast=()=>{};state.activeTab="stress";',context);
const reply=(i,p)=>requests[i].resolve({status:200,ok:true,text:async()=>JSON.stringify(p)}); const reply=(i,p)=>requests[i].resolve({status:200,ok:true,text:async()=>JSON.stringify(p)});
return {context,get,links,requests,calendars,reply,run:code=>vm.runInContext(code,context)}; return {context,get,links,forms,elements,requests,calendars,reply,run:code=>vm.runInContext(code,context)};
} }
test('test_pagination_fetches_only_page_and_preserves_chart',async()=>{ test('test_pagination_fetches_only_page_and_preserves_chart',async()=>{
const f=fixture(),initial=f.run('loadHealthView("stress")');assert.match(f.requests[0].url,/^\/proxy\/api\/views\/stress\?/);f.reply(0,summary());await initial; const f=fixture(),initial=f.run('loadHealthView("stress")');assert.match(f.requests[0].url,/^\/proxy\/api\/views\/stress\?/);f.reply(0,summary());await initial;
@@ -77,3 +77,14 @@ test('out-of-order overview loads preserve new range values and inspector',async
f.reply(1,{status:'ok',data:{steps:42}});await current;const inspector=f.get('inspectorBody').innerHTML; f.reply(1,{status:'ok',data:{steps:42}});await current;const inspector=f.get('inspectorBody').innerHTML;
f.reply(0,{status:'ok',data:{steps:999}});await old;assert.equal(f.get('statSteps').textContent,'42');assert.equal(f.get('inspectorBody').innerHTML,inspector);assert.match(f.get('overviewRange').textContent,/2026-10-16 至 2026-10-17/); f.reply(0,{status:'ok',data:{steps:999}});await old;assert.equal(f.get('statSteps').textContent,'42');assert.equal(f.get('inspectorBody').innerHTML,inspector);assert.match(f.get('overviewRange').textContent,/2026-10-16 至 2026-10-17/);
}); });
for(const source of ['input','state'])test(`native export submits the selected ${source} key only in a temporary POST form`,async()=>{
const f=fixture();
f.get('apiKeyInput').value=' synthetic-input-key ';
if(source==='state')f.run('state.apiKey="synthetic-state-key"');
const selected=source==='state'?'synthetic-state-key':'synthetic-input-key';
const initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial;
f.run('exportHealthRecords("stress")');assert.equal(f.forms.length,1);assert.equal(f.links.length,0);assert.equal(f.requests.length,1);
const form=f.forms[0];assert.equal(form.method,'POST');assert.match(form.action,/^\/proxy\/api\/views\/stress\/export\?/);assert.doesNotMatch(form.action,/synthetic|api_key|page=/);assert.deepEqual(form.fields,[{name:'api_key',value:selected,type:'hidden'}]);
const node=f.elements.find(element=>element.tag==='form');assert.equal(node.removed,true);assert.equal(node.children[0].value,'');assert.equal(node.autocomplete,'off');
});
+15 -3
View File
@@ -155,6 +155,8 @@ def create_app(
# Attach API Key (Server env priority, otherwise pass through client key) # Attach API Key (Server env priority, otherwise pass through client key)
server_api_key = app.config["API_KEY"] server_api_key = app.config["API_KEY"]
if server_api_key: if server_api_key:
forward_headers = {key: value for key, value in forward_headers.items()
if key.lower() != "x-api-key"}
forward_headers["X-API-Key"] = server_api_key forward_headers["X-API-Key"] = server_api_key
# Attach Admin Key if configured # Attach Admin Key if configured
@@ -166,12 +168,22 @@ def create_app(
forward_headers = {key: value for key, value in forward_headers.items() forward_headers = {key: value for key, value in forward_headers.items()
if key.lower() != "x-api-key"} if key.lower() != "x-api-key"}
body = request.get_data() if request.method in ("POST", "PUT", "PATCH", "DELETE") else None parts = clean_subpath.split("/")
is_export = (request.method in {"GET", "POST"} and len(parts) == 4
and parts[:2] == ["api", "views"] and parts[3] == "export"
and parts[2] in {"activity", "heartrate", "sleep", "workouts", "body", "spo2", "stress", "abnormal"})
body = request.get_data() if not is_export and request.method in ("POST", "PUT", "PATCH", "DELETE") else None
started = time.perf_counter() started = time.perf_counter()
try: try:
parts = clean_subpath.split("/") if is_export:
if request.method == "GET" and len(parts) == 4 and parts[:2] == ["api", "views"] and parts[3] == "export": # 仅导出接受原生下载表单;上游仍为无请求体的只读 GET。
export_key = server_api_key or (request.form.get("api_key") if request.method == "POST" else None)
if export_key:
forward_headers = {key: value for key, value in forward_headers.items() if key.lower() != "x-api-key"}
forward_headers["X-API-Key"] = export_key
if request.method == "POST":
forward_headers = {key: value for key, value in forward_headers.items() if key.lower() != "content-type"}
upstream_request = http_client.build_request("GET", target_url, headers=forward_headers) upstream_request = http_client.build_request("GET", target_url, headers=forward_headers)
upstream_resp = http_client.send(upstream_request, stream=True) upstream_resp = http_client.send(upstream_request, stream=True)
elapsed_ms = round((time.perf_counter() - started) * 1000, 2) elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
@@ -109,8 +109,18 @@ async function healthPage(kind,page){
} }
function exportHealthRecords(kind){ function exportHealthRecords(kind){
const saved=state.healthViews[kind];if(!saved||!saved.isCurrent())return; const saved=state.healthViews[kind];if(!saved||!saved.isCurrent())return;
const path='/proxy'+healthPath(kind,'/export'),key=getHeaders()['X-API-Key'];
if(key){
// 原生附件下载通过短暂表单传查询身份,Key 不进入 URL 或长期 DOM。
const form=document.createElement('form'),input=document.createElement('input');
form.method='POST';form.action=path;form.hidden=true;form.autocomplete='off';
input.type='hidden';input.name='api_key';input.value=key;
form.appendChild(input);document.body.appendChild(form);
try{form.submit();}finally{input.value='';form.remove();}
return;
}
const link=document.createElement('a'); const link=document.createElement('a');
link.href='/proxy'+healthPath(kind,'/export');link.download=''; link.href=path;link.download='';
document.body.appendChild(link);link.click();link.remove(); document.body.appendChild(link);link.click();link.remove();
} }
function initHealthViews(){ function initHealthViews(){
+81 -4
View File
@@ -29,13 +29,14 @@ def make_client(monkeypatch):
return app.test_client() return app.test_client()
@pytest.mark.parametrize("method", ["GET", "POST"])
@respx.mock @respx.mock
def test_stream_disconnect_closes_cursor_and_proxy_response(monkeypatch): def test_stream_disconnect_closes_cursor_and_proxy_response(monkeypatch, method):
stream = TrackedStream() stream = TrackedStream()
route = respx.get("http://synthetic-backend/api/views/stress/export").mock(return_value=httpx.Response(200, route = respx.get("http://synthetic-backend/api/views/stress/export").mock(return_value=httpx.Response(200,
stream=stream, headers={"Content-Type": "text/csv; charset=utf-8", "Content-Disposition": 'attachment; filename="synthetic.csv"', "X-API-Key": "must-not-leak", "X-Admin-Key": "must-not-leak", "Authorization": "must-not-leak", "Set-Cookie": "must-not-leak"})) stream=stream, headers={"Content-Type": "text/csv; charset=utf-8", "Content-Disposition": 'attachment; filename="synthetic.csv"', "X-API-Key": "must-not-leak", "X-Admin-Key": "must-not-leak", "Authorization": "must-not-leak", "Set-Cookie": "must-not-leak"}))
client = make_client(monkeypatch) client = make_client(monkeypatch)
response = client.get("/proxy/api/views/stress/export", auth=("admin", "synthetic-web-password"), buffered=False) response = client.open("/proxy/api/views/stress/export", method=method, data={"api_key": "synthetic-browser-key"} if method == "POST" else None, auth=("admin", "synthetic-web-password"), buffered=False)
assert response.status_code == 200 assert response.status_code == 200
assert response.is_streamed assert response.is_streamed
assert 0 < stream.consumed < 30 assert 0 < stream.consumed < 30
@@ -48,13 +49,14 @@ def test_stream_disconnect_closes_cursor_and_proxy_response(monkeypatch):
assert stream.consumed < 30 assert stream.consumed < 30
@pytest.mark.parametrize("method", ["GET", "POST"])
@respx.mock @respx.mock
def test_stream_normal_completion_and_error_release_upstream(monkeypatch): def test_stream_normal_completion_and_error_release_upstream(monkeypatch, method):
client = make_client(monkeypatch) client = make_client(monkeypatch)
for fail in [False, True]: for fail in [False, True]:
stream = TrackedStream(fail) stream = TrackedStream(fail)
respx.get("http://synthetic-backend/api/views/body/export").mock(return_value=httpx.Response(200, stream=stream, headers={"Content-Type": "text/csv"})) respx.get("http://synthetic-backend/api/views/body/export").mock(return_value=httpx.Response(200, stream=stream, headers={"Content-Type": "text/csv"}))
response = client.get("/proxy/api/views/body/export", auth=("admin", "synthetic-web-password"), buffered=False) response = client.open("/proxy/api/views/body/export", method=method, data={"api_key": "synthetic-browser-key"} if method == "POST" else None, auth=("admin", "synthetic-web-password"), buffered=False)
if fail: if fail:
with pytest.raises(httpx.ReadError): with pytest.raises(httpx.ReadError):
list(response.response) list(response.response)
@@ -78,3 +80,78 @@ def test_export_connection_failure_uses_existing_error_shape(monkeypatch):
assert response.status_code == 502 assert response.status_code == 502
assert response.json["error"] == "Flask 代理无法访问后端" assert response.json["error"] == "Flask 代理无法访问后端"
assert "X-Upstream-Time-Ms" in response.headers assert "X-Upstream-Time-Ms" in response.headers
@respx.mock
def test_form_export_uses_the_same_selected_user_as_summary_without_key_in_url(monkeypatch):
monkeypatch.setenv("MI_FITNESS_WEB_PASSWORD", "synthetic-web-password")
monkeypatch.delenv("MI_FITNESS_API_KEY", raising=False)
monkeypatch.delenv("MI_FITNESS_ADMIN_KEY", raising=False)
client = create_app(backend_url="http://synthetic-backend").test_client()
selected_key = "synthetic-selected-key"
seen = []
def user_response(request):
selected = request.headers.get("X-API-Key") == selected_key
seen.append(request)
if not selected:
return httpx.Response(401, json={"status": "error"})
if request.url.path.endswith("/export"):
return httpx.Response(200, content=b"synthetic-selected-user,csv", headers={"Content-Type": "text/csv", "Content-Disposition": 'attachment; filename="synthetic.csv"'})
return httpx.Response(200, json={"status": "ok", "synthetic_user": "synthetic-selected-user"})
respx.get("http://synthetic-backend/api/views/stress").mock(side_effect=user_response)
respx.get("http://synthetic-backend/api/views/stress/export").mock(side_effect=user_response)
auth = ("admin", "synthetic-web-password")
query = "?start_date=2026-10-01&end_date=2026-10-17&pressure_level=mild&limit=11"
summary = client.get("/proxy/api/views/stress" + query, auth=auth, headers={"X-API-Key": selected_key})
export = client.post("/proxy/api/views/stress/export" + query, data={"api_key": selected_key, "unexpected": "must-not-forward"}, auth=auth)
assert summary.status_code == export.status_code == 200
assert summary.json["synthetic_user"].encode() in export.data
assert len(seen) == 2
assert all(request.headers["X-API-Key"] == selected_key for request in seen)
assert seen[1].method == "GET"
assert seen[1].content == b""
assert selected_key not in str(seen[1].url)
assert "unexpected" not in str(seen[1].url)
assert seen[1].url.query == seen[0].url.query
assert "application/x-www-form-urlencoded" not in seen[1].headers.get("Content-Type", "")
export.close()
@respx.mock
def test_server_key_wins_over_form_and_browser_header(monkeypatch):
client = make_client(monkeypatch)
summary_route = respx.get("http://synthetic-backend/api/views/activity").mock(return_value=httpx.Response(200, json={"status": "ok"}))
summary = client.get("/proxy/api/views/activity", headers={"X-API-Key": "synthetic-header-key"}, auth=("admin", "synthetic-web-password"))
assert summary.status_code == 200
assert summary_route.calls[0].request.headers.get_list("X-API-Key") == ["synthetic-api-key"]
route = respx.get("http://synthetic-backend/api/views/activity/export").mock(return_value=httpx.Response(200, content=b"synthetic", headers={"Content-Type": "text/csv"}))
response = client.post("/proxy/api/views/activity/export", data={"api_key": "synthetic-form-key"}, headers={"X-API-Key": "synthetic-header-key"}, auth=("admin", "synthetic-web-password"))
assert response.status_code == 200
assert route.calls[0].request.headers.get_list("X-API-Key") == ["synthetic-api-key"]
assert route.calls[0].request.content == b""
response.close()
@respx.mock
def test_only_whitelisted_exports_translate_post_and_form_key(monkeypatch):
client = make_client(monkeypatch)
for path in ["api/views/not-a-kind/export", "api/views/stress/records", "api/sync"]:
route = respx.post("http://synthetic-backend/" + path).mock(return_value=httpx.Response(200, json={"status": "ok"}))
response = client.post("/proxy/" + path, data={"api_key": "synthetic-form-key"}, auth=("admin", "synthetic-web-password"))
assert response.status_code == 200
request = route.calls[0].request
assert request.method == "POST"
assert request.content == b"api_key=synthetic-form-key"
assert request.headers["X-API-Key"] == "synthetic-api-key"
@respx.mock
def test_form_export_retains_basic_host_and_origin_checks(monkeypatch):
client = make_client(monkeypatch)
path = "/proxy/api/views/stress/export"
assert client.post(path, data={"api_key": "synthetic"}).status_code == 401
auth = ("admin", "synthetic-web-password")
assert client.post(path, data={"api_key": "synthetic"}, auth=auth, headers={"Origin": "https://untrusted.example"}).status_code == 403
assert client.post(path, data={"api_key": "synthetic"}, auth=auth, headers={"Host": "untrusted.example"}).status_code == 403