mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-17 18:55:37 +08:00
fix: preserve selected identity in streamed health exports
This commit is contained in:
1 parent
bcd4cd3759
commit
6292755ff9
4 files changed
+121
-11
No files matched your search
@@ -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');
|
||||||
|
});
|
||||||
@@ -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(){
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user