From c4d6b968f33db13e66c4d127033e222c02af5608 Mon Sep 17 00:00:00 2001 From: JKH Date: Sun, 19 Apr 2026 19:22:35 +0900 Subject: [PATCH 1/7] =?UTF-8?q?chore:=20=EB=B0=B0=ED=8F=AC=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20(GHCR=20=ED=86=A0=ED=81=B0=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=ED=9B=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From d5657d41e764b0bfe0683ada0f8b8263c603e7a6 Mon Sep 17 00:00:00 2001 From: JKH Date: Sun, 19 Apr 2026 19:40:42 +0900 Subject: [PATCH 2/7] =?UTF-8?q?fix(ci):=20GHCR=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=EC=9D=84=20GITHUB=5FTOKEN=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PAT 기반 GHCR_TOKEN이 denied 에러를 내는 문제 해결. - docker/login-action@v3로 교체하고 자동 발급되는 GITHUB_TOKEN 사용 - permissions: packages: write 추가 - EC2 pull도 동일 토큰으로 인증 (envs로 전달) --- .github/workflows/deploy.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 952354d..56fc207 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,11 +7,18 @@ on: jobs: deploy: runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - uses: actions/checkout@v4 - name: Login to GHCR - run: echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Docker image run: | @@ -26,15 +33,18 @@ jobs: host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} + envs: GHCR_PULL_TOKEN script: | set -e cd ~/Project_RoboSenseAPI git pull origin main - echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + echo "$GHCR_PULL_TOKEN" | docker login ghcr.io -u jeonggihun --password-stdin docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml up -d sleep 15 curl -f http://localhost/health || (echo "Health check failed, rolling back" && docker compose -f docker-compose.prod.yml down && docker compose -f docker-compose.prod.yml up -d --force-recreate && exit 1) + env: + GHCR_PULL_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Verify deployment uses: appleboy/ssh-action@v1 From 9d1fba5645900121c117bc8f1bc6302190071e5d Mon Sep 17 00:00:00 2001 From: JKH Date: Sun, 19 Apr 2026 20:51:21 +0900 Subject: [PATCH 3/7] =?UTF-8?q?chore:=20EC2=5FHOST=20secret=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=ED=9B=84=20=EC=9E=AC=EB=B0=B0=ED=8F=AC=20=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a934532b49ef3a8fa1e4bef8e26c76362b9a94c0 Mon Sep 17 00:00:00 2001 From: JKH Date: Mon, 20 Apr 2026 02:29:23 +0900 Subject: [PATCH 4/7] =?UTF-8?q?chore:=20GHCR=5FTOKEN=20secret=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=ED=9B=84=20=EC=9E=AC=EB=B0=B0=ED=8F=AC=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 3f539c6e0817560c8a724cf12b68e1512e238527 Mon Sep 17 00:00:00 2001 From: JKH Date: Mon, 20 Apr 2026 03:28:23 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=EB=9E=9C=EB=94=A9=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=9A=A9=20BFF=20=ED=94=84=EB=A1=9D=EC=8B=9C?= =?UTF-8?q?=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 랜딩페이지 JS가 브라우저에 API Key를 노출하지 않고 데모를 실행하도록 `/demo/*` 프록시 라우트를 추가. 내부에서 기존 `/api/*` 핸들러를 직접 호출. - 기존 `/api/*` 는 API Key 인증 유지 (서버-to-서버 용도) - `/demo/*` 는 인증 없이 호출 가능 (브라우저 데모 전용) - 파괴적 작업 (`DELETE /api/reset`) 은 의도적으로 프록시하지 않음 - 랜딩페이지 JS 경로 `/api/*` → `/demo/*` 로 전환 (reset 제외) 테스트 7개 추가 (BFF 동작 + `/api/*` 회귀 확인). --- app/main.py | 3 +- app/routes/demo_routes.py | 93 +++++++++++++++++++++++++++++++++++++++ index.html | 22 ++++----- test/test_demo_bff.py | 76 ++++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 12 deletions(-) create mode 100644 app/routes/demo_routes.py create mode 100644 test/test_demo_bff.py diff --git a/app/main.py b/app/main.py index 10bd48f..01d054e 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.cors import CORSMiddleware -from app.routes import sensor_routes, robot_routes, stats_routes, admin_routes +from app.routes import sensor_routes, robot_routes, stats_routes, admin_routes, demo_routes from app.database import engine, Base, init_asyncpg_pool, close_asyncpg_pool, get_asyncpg_pool from app.middleware import RequestIDMiddleware from app.logging_config import RequestIDFilter @@ -77,6 +77,7 @@ async def lifespan(app: FastAPI): app.include_router(robot_routes.router) app.include_router(stats_routes.router) app.include_router(admin_routes.router) +app.include_router(demo_routes.router) @app.get("/", response_class=HTMLResponse) def root() : diff --git a/app/routes/demo_routes.py b/app/routes/demo_routes.py new file mode 100644 index 0000000..37e61e8 --- /dev/null +++ b/app/routes/demo_routes.py @@ -0,0 +1,93 @@ +"""BFF 프록시 라우트. + +랜딩페이지(브라우저)가 API Key 없이 호출하는 데모용 엔드포인트. +내부에서 기존 `/api/*` 핸들러를 그대로 호출 — 인증만 우회. + +API Key는 서버-to-서버 인증 용도이므로 브라우저 노출 금지. +데모 페이지는 이 프록시로 흘리고, 실제 `/api/*` 는 API Key 보호 유지. + +파괴적 작업(`DELETE /api/reset`)은 의도적으로 프록시하지 않음 — admin 전용. +""" +from fastapi import APIRouter, Depends, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +from typing import Optional +from datetime import datetime + +from app.database import get_db +from app.models.robot import RobotCreate, RobotResponse, RobotDetailResponse, RobotStatusUpdate +from app.models.sensor import SensorDataCreate, SensorListResponse, SensorResponse, FilteredSensorResponse +from app.routes import robot_routes, sensor_routes, stats_routes + +router = APIRouter(prefix="/demo", tags=["데모 (BFF)"]) + + +@router.get("/robots", response_model=list[RobotResponse]) +async def demo_list_robots( + status: Optional[str] = Query(None), + db: AsyncSession = Depends(get_db), +): + return await robot_routes.robot_data_list(status=status, db=db, _key=None) + + +@router.post("/robots", response_model=RobotResponse, status_code=status.HTTP_201_CREATED) +async def demo_register_robot(data: RobotCreate, db: AsyncSession = Depends(get_db)): + return await robot_routes.registration_robot_data(data=data, db=db, _key=None) + + +@router.get("/robots/{id}", response_model=RobotDetailResponse) +async def demo_get_robot(id: int, db: AsyncSession = Depends(get_db)): + return await robot_routes.robot_data_specific_list(id=id, db=db, _key=None) + + +@router.put("/robots/{robot_id}", response_model=RobotResponse) +async def demo_update_robot( + robot_id: int, + update_data: RobotStatusUpdate, + db: AsyncSession = Depends(get_db), +): + return await robot_routes.update_robot_status( + robot_id=robot_id, update_data=update_data, db=db, _key=None + ) + + +@router.post("/sensors", status_code=status.HTTP_201_CREATED) +async def demo_collect_sensor(data: SensorDataCreate): + return await sensor_routes.collect_sensor_data(data=data, _key=None) + + +@router.get("/sensors", response_model=SensorListResponse) +async def demo_list_sensors( + limit: int = Query(100, ge=1, le=1000), + robot_id: Optional[int] = None, + sensor_type: Optional[str] = None, + cursor_id: Optional[int] = None, + db: AsyncSession = Depends(get_db), +): + return await sensor_routes.check_filter_data( + limit=limit, robot_id=robot_id, sensor_type=sensor_type, + cursor_id=cursor_id, db=db, _key=None, + ) + + +@router.get("/sensors/filtered", response_model=FilteredSensorResponse) +async def demo_filtered_sensors(robot_id: int, sensor_type: str, field: str, window_size: int): + return await sensor_routes.check_filter_sensor_data( + robot_id=robot_id, sensor_type=sensor_type, + field=field, window_size=window_size, _key=None, + ) + + +@router.get("/sensors/{id}", response_model=SensorResponse) +async def demo_get_sensor(id: int, db: AsyncSession = Depends(get_db)): + return await sensor_routes.check_filter_specific_data(id=id, db=db, _key=None) + + +@router.get("/stats") +async def demo_stats( + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, + db: AsyncSession = Depends(get_db), +): + return await stats_routes.get_stats( + start_time=start_time, end_time=end_time, db=db, _key=None, + ) diff --git a/index.html b/index.html index ba7b348..c0c99f9 100644 --- a/index.html +++ b/index.html @@ -301,7 +301,7 @@
Registered Robots
var MODELS=['Warehouse-Bot-v2','Patrol-Bot-X1','Cargo-Mover-3K','Scout-Mini','HeavyLift-900','SpeedRunner-S','Inspector-Pro','CleanBot-Z','Picker-Alpha','Navigator-V5']; var autoTimer=null,autoSentN=0,autoErrN=0,autoTotalMs=0; -function postRobot(){showLoad('resp-post-robot');api('POST','/api/robots',{name:document.getElementById('robot-name').value,model:document.getElementById('robot-model').value,status:document.getElementById('robot-status').value,battery_level:parseInt(document.getElementById('robot-battery').value)}).then(function(r){showResp('resp-post-robot',r.status,r.data,r.ms);if(r.status===201){R.push({id:r.data.id,name:r.data.name});updateRL();unlock();}}).catch(function(e){showErr('resp-post-robot',e.message);});} +function postRobot(){showLoad('resp-post-robot');api('POST','/demo/robots',{name:document.getElementById('robot-name').value,model:document.getElementById('robot-model').value,status:document.getElementById('robot-status').value,battery_level:parseInt(document.getElementById('robot-battery').value)}).then(function(r){showResp('resp-post-robot',r.status,r.data,r.ms);if(r.status===201){R.push({id:r.data.id,name:r.data.name});updateRL();unlock();}}).catch(function(e){showErr('resp-post-robot',e.message);});} function autoGenRobots(){ showLoad('resp-post-robot'); @@ -315,7 +315,7 @@
Registered Robots
} var i=count;count++; var statuses=['active','active','active','active','active','active','active','inactive','maintenance','active']; - api('POST','/api/robots',{name:'Robot-'+String.fromCharCode(65+i),model:MODELS[i],status:statuses[i],battery_level:Math.floor(60+Math.random()*40)}).then(function(r){ + api('POST','/demo/robots',{name:'Robot-'+String.fromCharCode(65+i),model:MODELS[i],status:statuses[i],battery_level:Math.floor(60+Math.random()*40)}).then(function(r){ if(r.status===201){ok++;R.push({id:r.data.id,name:r.data.name});results.push({id:r.data.id,name:r.data.name});}else{err++;} next(); }).catch(function(){err++;next();}); @@ -332,10 +332,10 @@
Registered Robots
}).catch(function(e){showErr('resp-post-robot',e.message);}); } -function getRobots(){showLoad('resp-get-robots');var s=document.getElementById('robots-list-status').value;api('GET','/api/robots'+(s?'?status='+s:'')).then(function(r){showResp('resp-get-robots',r.status,r.data,r.ms);if(r.status===200&&Array.isArray(r.data)&&r.data.length){R=r.data.map(function(x){return{id:x.id,name:x.name};});updateRL();unlock();}}).catch(function(e){showErr('resp-get-robots',e.message);});} -function getRobotDetail(){showLoad('resp-get-robot-detail');api('GET','/api/robots/'+document.getElementById('robot-detail-id').value).then(function(r){showResp('resp-get-robot-detail',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-get-robot-detail',e.message);});} -function putRobot(){showLoad('resp-put-robot');var id=document.getElementById('robot-update-id').value;api('PUT','/api/robots/'+id,{status:document.getElementById('robot-update-status').value,battery_level:parseInt(document.getElementById('robot-update-battery').value)}).then(function(r){showResp('resp-put-robot',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-put-robot',e.message);});} -function postSensor(){showLoad('resp-post-sensor');try{api('POST','/api/sensors',JSON.parse(document.getElementById('sensor-body').value)).then(function(r){showResp('resp-post-sensor',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-post-sensor',e.message);});}catch(e){showErr('resp-post-sensor','Invalid JSON');}} +function getRobots(){showLoad('resp-get-robots');var s=document.getElementById('robots-list-status').value;api('GET','/demo/robots'+(s?'?status='+s:'')).then(function(r){showResp('resp-get-robots',r.status,r.data,r.ms);if(r.status===200&&Array.isArray(r.data)&&r.data.length){R=r.data.map(function(x){return{id:x.id,name:x.name};});updateRL();unlock();}}).catch(function(e){showErr('resp-get-robots',e.message);});} +function getRobotDetail(){showLoad('resp-get-robot-detail');api('GET','/demo/robots/'+document.getElementById('robot-detail-id').value).then(function(r){showResp('resp-get-robot-detail',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-get-robot-detail',e.message);});} +function putRobot(){showLoad('resp-put-robot');var id=document.getElementById('robot-update-id').value;api('PUT','/demo/robots/'+id,{status:document.getElementById('robot-update-status').value,battery_level:parseInt(document.getElementById('robot-update-battery').value)}).then(function(r){showResp('resp-put-robot',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-put-robot',e.message);});} +function postSensor(){showLoad('resp-post-sensor');try{api('POST','/demo/sensors',JSON.parse(document.getElementById('sensor-body').value)).then(function(r){showResp('resp-post-sensor',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-post-sensor',e.message);});}catch(e){showErr('resp-post-sensor','Invalid JSON');}} function genSensorData(rid){ var now=new Date().toISOString().slice(0,19); @@ -372,7 +372,7 @@
Registered Robots
var rid=R[Math.floor(Math.random()*R.length)].id; var data=genSensorData(rid); document.getElementById('sensor-body').value=JSON.stringify(data,null,2); - api('POST','/api/sensors',data).then(function(r){ + api('POST','/demo/sensors',data).then(function(r){ autoSentN++; if(r.status>=200&&r.status<300){autoTotalMs+=r.ms;}else{autoErrN++;} document.getElementById('autoSent').textContent=autoSentN; @@ -382,15 +382,15 @@
Registered Robots
}).catch(function(){autoErrN++;document.getElementById('autoErrors').textContent=autoErrN;}); } -function getSensors(){showLoad('resp-get-sensors');var p=[],rid=parseInt(document.getElementById('sensor-list-robot-id').value),st=document.getElementById('sensor-list-type').value,lim=document.getElementById('sensor-list-limit').value;if(rid&&rid>0)p.push('robot_id='+rid);else if(rid&&rid<=0){showErr('resp-get-sensors','Robot ID는 1 이상이어야 합니다');return;}if(st)p.push('sensor_type='+st);if(lim)p.push('limit='+lim);api('GET','/api/sensors'+(p.length?'?'+p.join('&'):'')).then(function(r){showResp('resp-get-sensors',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-get-sensors',e.message);});} -function getFiltered(){showLoad('resp-get-filtered');api('GET','/api/sensors/filtered?robot_id='+document.getElementById('filter-robot-id').value+'&sensor_type='+document.getElementById('filter-sensor-type').value+'&field='+document.getElementById('filter-field').value+'&window_size='+document.getElementById('filter-window').value).then(function(r){showResp('resp-get-filtered',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-get-filtered',e.message);});} -function getStats(){showLoad('resp-get-stats');var p=[],s=document.getElementById('stats-start').value,e=document.getElementById('stats-end').value;if(s)p.push('start_time='+new Date(s).toISOString());if(e)p.push('end_time='+new Date(e).toISOString());api('GET','/api/stats'+(p.length?'?'+p.join('&'):'')).then(function(r){showResp('resp-get-stats',r.status,r.data,r.ms);}).catch(function(e2){showErr('resp-get-stats',e2.message);});} +function getSensors(){showLoad('resp-get-sensors');var p=[],rid=parseInt(document.getElementById('sensor-list-robot-id').value),st=document.getElementById('sensor-list-type').value,lim=document.getElementById('sensor-list-limit').value;if(rid&&rid>0)p.push('robot_id='+rid);else if(rid&&rid<=0){showErr('resp-get-sensors','Robot ID는 1 이상이어야 합니다');return;}if(st)p.push('sensor_type='+st);if(lim)p.push('limit='+lim);api('GET','/demo/sensors'+(p.length?'?'+p.join('&'):'')).then(function(r){showResp('resp-get-sensors',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-get-sensors',e.message);});} +function getFiltered(){showLoad('resp-get-filtered');api('GET','/demo/sensors/filtered?robot_id='+document.getElementById('filter-robot-id').value+'&sensor_type='+document.getElementById('filter-sensor-type').value+'&field='+document.getElementById('filter-field').value+'&window_size='+document.getElementById('filter-window').value).then(function(r){showResp('resp-get-filtered',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-get-filtered',e.message);});} +function getStats(){showLoad('resp-get-stats');var p=[],s=document.getElementById('stats-start').value,e=document.getElementById('stats-end').value;if(s)p.push('start_time='+new Date(s).toISOString());if(e)p.push('end_time='+new Date(e).toISOString());api('GET','/demo/stats'+(p.length?'?'+p.join('&'):'')).then(function(r){showResp('resp-get-stats',r.status,r.data,r.ms);}).catch(function(e2){showErr('resp-get-stats',e2.message);});} function healthCheck(){showLoad('resp-health');api('GET','/health_check').then(function(r){showResp('resp-health',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-health',e.message);});} function toLocal(d){return new Date(d.getTime()-d.getTimezoneOffset()*60000).toISOString().slice(0,16);} var pageLoad=new Date(); document.getElementById('stats-start').value=toLocal(pageLoad); document.getElementById('stats-end').value=toLocal(new Date(pageLoad.getTime()+3600000)); -fetch(B+'/api/robots').then(function(r){return r.json();}).then(function(d){if(Array.isArray(d)&&d.length){R=d.map(function(x){return{id:x.id,name:x.name};});updateRL();unlock();}}).catch(function(){}); +fetch(B+'/demo/robots').then(function(r){return r.json();}).then(function(d){if(Array.isArray(d)&&d.length){R=d.map(function(x){return{id:x.id,name:x.name};});updateRL();unlock();}}).catch(function(){}); diff --git a/test/test_demo_bff.py b/test/test_demo_bff.py new file mode 100644 index 0000000..df0cf08 --- /dev/null +++ b/test/test_demo_bff.py @@ -0,0 +1,76 @@ +"""BFF demo 프록시 라우트 테스트. + +랜딩페이지 JS가 키 없이 호출하는 경로. 서버가 내부에서 API Key 없이도 통과시킴. +""" +from httpx import AsyncClient, ASGITransport +from app.main import app +from app.models.db_models import Robot + + +async def _client_without_key(): + transport = ASGITransport(app=app, raise_app_exceptions=False) + return AsyncClient(transport=transport, base_url="http://test") + + +async def test_demo_get_robots_empty(client): + """키 없이 GET /demo/robots 호출 가능 (빈 리스트)""" + del client.headers["X-API-Key"] + response = await client.get("/demo/robots") + assert response.status_code == 200 + assert response.json() == [] + + +async def test_demo_get_robots_with_data(client, db_session): + """GET /demo/robots 가 데이터 반환""" + db_session.add(Robot(name="Demo1", model="m1", status="active", battery_level=70)) + await db_session.commit() + + del client.headers["X-API-Key"] + response = await client.get("/demo/robots") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["name"] == "Demo1" + + +async def test_demo_post_robot(client): + """키 없이 POST /demo/robots 로 로봇 생성 가능""" + del client.headers["X-API-Key"] + response = await client.post( + "/demo/robots", + json={"name": "NewBot", "model": "v1", "status": "active", "battery_level": 90}, + ) + assert response.status_code == 201 + assert response.json()["name"] == "NewBot" + + +async def test_demo_get_sensors(client): + """키 없이 GET /demo/sensors""" + del client.headers["X-API-Key"] + response = await client.get("/demo/sensors?limit=5") + assert response.status_code == 200 + body = response.json() + assert "data" in body + assert "next_cursor" in body + + +async def test_demo_stats(client): + """키 없이 GET /demo/stats""" + del client.headers["X-API-Key"] + response = await client.get("/demo/stats") + assert response.status_code == 200 + + +async def test_api_routes_still_require_key(client): + """회귀: /api/* 는 여전히 API Key 필수 (BFF 추가해도 기존 보호 유지)""" + del client.headers["X-API-Key"] + response = await client.get("/api/robots") + assert response.status_code == 401 + assert response.json()["error_code"] == "API_KEY_MISSING" + + +async def test_demo_does_not_expose_reset(client): + """DELETE /demo/reset 은 제공되지 않음 (데이터 초기화는 admin 키로만)""" + del client.headers["X-API-Key"] + response = await client.delete("/demo/reset") + assert response.status_code == 404 From 61ac486e70b302f06b80d0e3a1241c482dc3b81c Mon Sep 17 00:00:00 2001 From: JKH Date: Mon, 20 Apr 2026 03:56:34 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20demo=20=ED=94=84=EB=A1=9D?= =?UTF-8?q?=EC=8B=9C=EB=A5=BC=20=EC=A1=B0=ED=9A=8C=20=EC=A0=84=EC=9A=A9?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 익명 쓰기를 허용하면 rate limit 없는 상태에서 DB 오염 가능. 포트폴리오 시연 + 보안 요구를 동시에 만족하려면 조회만 공개하는 게 맞음. - /demo/* 에서 POST/PUT 라우트 제거 (조회 엔드포인트만 유지) - 랜딩 JS: 쓰기 버튼은 /api/* 로 복귀 (키 없으면 401 = 의도한 응답) - 랜딩 상단에 인증 안내 배너 추가 (GET=공개, 쓰기=키 필요) - 테스트 3개 추가 (쓰기 라우트 차단 확인), 회귀 테스트 2개 --- app/routes/demo_routes.py | 44 ++++++-------------- index.html | 11 ++--- test/test_demo_bff.py | 86 +++++++++++++++++++++++++-------------- 3 files changed, 73 insertions(+), 68 deletions(-) diff --git a/app/routes/demo_routes.py b/app/routes/demo_routes.py index 37e61e8..169d0db 100644 --- a/app/routes/demo_routes.py +++ b/app/routes/demo_routes.py @@ -1,24 +1,25 @@ -"""BFF 프록시 라우트. +"""랜딩페이지용 공개 조회 엔드포인트. -랜딩페이지(브라우저)가 API Key 없이 호출하는 데모용 엔드포인트. -내부에서 기존 `/api/*` 핸들러를 그대로 호출 — 인증만 우회. +API Key 없이 접근 가능. **조회 전용**. +쓰기는 `/api/*` 에서만 가능 (서버-to-서버 통합은 API Key 필수). -API Key는 서버-to-서버 인증 용도이므로 브라우저 노출 금지. -데모 페이지는 이 프록시로 흘리고, 실제 `/api/*` 는 API Key 보호 유지. - -파괴적 작업(`DELETE /api/reset`)은 의도적으로 프록시하지 않음 — admin 전용. +왜 쓰기를 제공하지 않나: +- 익명 쓰기를 열면 봇·스크립트가 DB 오염 가능 (rate limit 없음) +- RoboSense는 로봇/IoT 기기 통합 API라 사람 로그인 개념이 없음 + → 쓰기 권한을 익명 브라우저 세션에 부여할 근거가 없음 +- 조회만 공개하면 포트폴리오 시연 요구 + 보안 요구 둘 다 만족 """ -from fastapi import APIRouter, Depends, status, Query +from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from typing import Optional from datetime import datetime from app.database import get_db -from app.models.robot import RobotCreate, RobotResponse, RobotDetailResponse, RobotStatusUpdate -from app.models.sensor import SensorDataCreate, SensorListResponse, SensorResponse, FilteredSensorResponse +from app.models.robot import RobotResponse, RobotDetailResponse +from app.models.sensor import SensorListResponse, SensorResponse, FilteredSensorResponse from app.routes import robot_routes, sensor_routes, stats_routes -router = APIRouter(prefix="/demo", tags=["데모 (BFF)"]) +router = APIRouter(prefix="/demo", tags=["랜딩페이지 공개 조회"]) @router.get("/robots", response_model=list[RobotResponse]) @@ -29,32 +30,11 @@ async def demo_list_robots( return await robot_routes.robot_data_list(status=status, db=db, _key=None) -@router.post("/robots", response_model=RobotResponse, status_code=status.HTTP_201_CREATED) -async def demo_register_robot(data: RobotCreate, db: AsyncSession = Depends(get_db)): - return await robot_routes.registration_robot_data(data=data, db=db, _key=None) - - @router.get("/robots/{id}", response_model=RobotDetailResponse) async def demo_get_robot(id: int, db: AsyncSession = Depends(get_db)): return await robot_routes.robot_data_specific_list(id=id, db=db, _key=None) -@router.put("/robots/{robot_id}", response_model=RobotResponse) -async def demo_update_robot( - robot_id: int, - update_data: RobotStatusUpdate, - db: AsyncSession = Depends(get_db), -): - return await robot_routes.update_robot_status( - robot_id=robot_id, update_data=update_data, db=db, _key=None - ) - - -@router.post("/sensors", status_code=status.HTTP_201_CREATED) -async def demo_collect_sensor(data: SensorDataCreate): - return await sensor_routes.collect_sensor_data(data=data, _key=None) - - @router.get("/sensors", response_model=SensorListResponse) async def demo_list_sensors( limit: int = Query(100, ge=1, le=1000), diff --git a/index.html b/index.html index c0c99f9..8d36c6b 100644 --- a/index.html +++ b/index.html @@ -166,6 +166,7 @@

RoboSense API

API 테스트

실제 서버에 요청을 보내고 응답을 확인하세요. 모든 기능은 로봇 등록 후 사용할 수 있습니다.

Step 1 필수 — 센서 데이터 수집, 필터링, 통계 등 모든 기능은 로봇 등록(POST /api/robots) 이후에만 사용할 수 있습니다.
+
🔒
인증 안내조회(GET)는 공개 엔드포인트(/demo/*)로 키 없이 동작합니다. 쓰기(POST/PUT/DELETE)/api/* + X-API-Key 헤더 필요 — 버튼 클릭 시 401이 정상 응답입니다.
1

로봇 등록

POST /api/robots

2

센서 데이터 수집

POST /api/sensors

@@ -301,7 +302,7 @@
Registered Robots
var MODELS=['Warehouse-Bot-v2','Patrol-Bot-X1','Cargo-Mover-3K','Scout-Mini','HeavyLift-900','SpeedRunner-S','Inspector-Pro','CleanBot-Z','Picker-Alpha','Navigator-V5']; var autoTimer=null,autoSentN=0,autoErrN=0,autoTotalMs=0; -function postRobot(){showLoad('resp-post-robot');api('POST','/demo/robots',{name:document.getElementById('robot-name').value,model:document.getElementById('robot-model').value,status:document.getElementById('robot-status').value,battery_level:parseInt(document.getElementById('robot-battery').value)}).then(function(r){showResp('resp-post-robot',r.status,r.data,r.ms);if(r.status===201){R.push({id:r.data.id,name:r.data.name});updateRL();unlock();}}).catch(function(e){showErr('resp-post-robot',e.message);});} +function postRobot(){showLoad('resp-post-robot');api('POST','/api/robots',{name:document.getElementById('robot-name').value,model:document.getElementById('robot-model').value,status:document.getElementById('robot-status').value,battery_level:parseInt(document.getElementById('robot-battery').value)}).then(function(r){showResp('resp-post-robot',r.status,r.data,r.ms);if(r.status===201){R.push({id:r.data.id,name:r.data.name});updateRL();unlock();}}).catch(function(e){showErr('resp-post-robot',e.message);});} function autoGenRobots(){ showLoad('resp-post-robot'); @@ -315,7 +316,7 @@
Registered Robots
} var i=count;count++; var statuses=['active','active','active','active','active','active','active','inactive','maintenance','active']; - api('POST','/demo/robots',{name:'Robot-'+String.fromCharCode(65+i),model:MODELS[i],status:statuses[i],battery_level:Math.floor(60+Math.random()*40)}).then(function(r){ + api('POST','/api/robots',{name:'Robot-'+String.fromCharCode(65+i),model:MODELS[i],status:statuses[i],battery_level:Math.floor(60+Math.random()*40)}).then(function(r){ if(r.status===201){ok++;R.push({id:r.data.id,name:r.data.name});results.push({id:r.data.id,name:r.data.name});}else{err++;} next(); }).catch(function(){err++;next();}); @@ -334,8 +335,8 @@
Registered Robots
function getRobots(){showLoad('resp-get-robots');var s=document.getElementById('robots-list-status').value;api('GET','/demo/robots'+(s?'?status='+s:'')).then(function(r){showResp('resp-get-robots',r.status,r.data,r.ms);if(r.status===200&&Array.isArray(r.data)&&r.data.length){R=r.data.map(function(x){return{id:x.id,name:x.name};});updateRL();unlock();}}).catch(function(e){showErr('resp-get-robots',e.message);});} function getRobotDetail(){showLoad('resp-get-robot-detail');api('GET','/demo/robots/'+document.getElementById('robot-detail-id').value).then(function(r){showResp('resp-get-robot-detail',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-get-robot-detail',e.message);});} -function putRobot(){showLoad('resp-put-robot');var id=document.getElementById('robot-update-id').value;api('PUT','/demo/robots/'+id,{status:document.getElementById('robot-update-status').value,battery_level:parseInt(document.getElementById('robot-update-battery').value)}).then(function(r){showResp('resp-put-robot',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-put-robot',e.message);});} -function postSensor(){showLoad('resp-post-sensor');try{api('POST','/demo/sensors',JSON.parse(document.getElementById('sensor-body').value)).then(function(r){showResp('resp-post-sensor',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-post-sensor',e.message);});}catch(e){showErr('resp-post-sensor','Invalid JSON');}} +function putRobot(){showLoad('resp-put-robot');var id=document.getElementById('robot-update-id').value;api('PUT','/api/robots/'+id,{status:document.getElementById('robot-update-status').value,battery_level:parseInt(document.getElementById('robot-update-battery').value)}).then(function(r){showResp('resp-put-robot',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-put-robot',e.message);});} +function postSensor(){showLoad('resp-post-sensor');try{api('POST','/api/sensors',JSON.parse(document.getElementById('sensor-body').value)).then(function(r){showResp('resp-post-sensor',r.status,r.data,r.ms);}).catch(function(e){showErr('resp-post-sensor',e.message);});}catch(e){showErr('resp-post-sensor','Invalid JSON');}} function genSensorData(rid){ var now=new Date().toISOString().slice(0,19); @@ -372,7 +373,7 @@
Registered Robots
var rid=R[Math.floor(Math.random()*R.length)].id; var data=genSensorData(rid); document.getElementById('sensor-body').value=JSON.stringify(data,null,2); - api('POST','/demo/sensors',data).then(function(r){ + api('POST','/api/sensors',data).then(function(r){ autoSentN++; if(r.status>=200&&r.status<300){autoTotalMs+=r.ms;}else{autoErrN++;} document.getElementById('autoSent').textContent=autoSentN; diff --git a/test/test_demo_bff.py b/test/test_demo_bff.py index df0cf08..cc11304 100644 --- a/test/test_demo_bff.py +++ b/test/test_demo_bff.py @@ -1,19 +1,14 @@ -"""BFF demo 프록시 라우트 테스트. +"""demo 프록시 라우트 테스트. -랜딩페이지 JS가 키 없이 호출하는 경로. 서버가 내부에서 API Key 없이도 통과시킴. +랜딩페이지용 공개 **조회 전용** 엔드포인트. 쓰기는 여전히 `/api/*`(API Key 필요). """ -from httpx import AsyncClient, ASGITransport -from app.main import app from app.models.db_models import Robot -async def _client_without_key(): - transport = ASGITransport(app=app, raise_app_exceptions=False) - return AsyncClient(transport=transport, base_url="http://test") - +# === 읽기 허용 === async def test_demo_get_robots_empty(client): - """키 없이 GET /demo/robots 호출 가능 (빈 리스트)""" + """키 없이 GET /demo/robots 호출 가능""" del client.headers["X-API-Key"] response = await client.get("/demo/robots") assert response.status_code == 200 @@ -28,20 +23,7 @@ async def test_demo_get_robots_with_data(client, db_session): del client.headers["X-API-Key"] response = await client.get("/demo/robots") assert response.status_code == 200 - data = response.json() - assert len(data) == 1 - assert data[0]["name"] == "Demo1" - - -async def test_demo_post_robot(client): - """키 없이 POST /demo/robots 로 로봇 생성 가능""" - del client.headers["X-API-Key"] - response = await client.post( - "/demo/robots", - json={"name": "NewBot", "model": "v1", "status": "active", "battery_level": 90}, - ) - assert response.status_code == 201 - assert response.json()["name"] == "NewBot" + assert len(response.json()) == 1 async def test_demo_get_sensors(client): @@ -49,9 +31,6 @@ async def test_demo_get_sensors(client): del client.headers["X-API-Key"] response = await client.get("/demo/sensors?limit=5") assert response.status_code == 200 - body = response.json() - assert "data" in body - assert "next_cursor" in body async def test_demo_stats(client): @@ -61,16 +40,61 @@ async def test_demo_stats(client): assert response.status_code == 200 +# === 쓰기 차단 === + +async def test_demo_post_robot_not_allowed(client): + """POST /demo/robots 는 제공되지 않음 — 쓰기는 /api/* + API Key 필요""" + del client.headers["X-API-Key"] + response = await client.post( + "/demo/robots", + json={"name": "X", "model": "v1", "status": "active", "battery_level": 50}, + ) + assert response.status_code in (404, 405) + + +async def test_demo_put_robot_not_allowed(client, db_session): + """PUT /demo/robots/{id} 도 제공되지 않음 (로봇 실존해도 경로 자체 없음)""" + db_session.add(Robot(name="R1", model="m", status="active", battery_level=50)) + await db_session.commit() + + del client.headers["X-API-Key"] + response = await client.put( + "/demo/robots/1", + json={"status": "inactive", "battery_level": 30}, + ) + assert response.status_code in (404, 405) + + +async def test_demo_post_sensor_not_allowed(client): + """POST /demo/sensors 도 제공되지 않음""" + del client.headers["X-API-Key"] + response = await client.post( + "/demo/sensors", + json={"robot_id": 1, "timestamp": "2026-01-01T00:00:00", "sensors": []}, + ) + assert response.status_code in (404, 405) + + +async def test_demo_does_not_expose_reset(client): + """DELETE /demo/reset 도 제공되지 않음""" + del client.headers["X-API-Key"] + response = await client.delete("/demo/reset") + assert response.status_code == 404 + + +# === 회귀: /api/* 는 여전히 API Key 필수 === + async def test_api_routes_still_require_key(client): - """회귀: /api/* 는 여전히 API Key 필수 (BFF 추가해도 기존 보호 유지)""" del client.headers["X-API-Key"] response = await client.get("/api/robots") assert response.status_code == 401 assert response.json()["error_code"] == "API_KEY_MISSING" -async def test_demo_does_not_expose_reset(client): - """DELETE /demo/reset 은 제공되지 않음 (데이터 초기화는 admin 키로만)""" +async def test_api_post_still_requires_key(client): del client.headers["X-API-Key"] - response = await client.delete("/demo/reset") - assert response.status_code == 404 + response = await client.post( + "/api/robots", + json={"name": "X", "model": "v1", "status": "active", "battery_level": 50}, + ) + assert response.status_code == 401 From 4a568188fcdf72bb76e0c6936398aff72e65244f Mon Sep 17 00:00:00 2001 From: JKH Date: Sat, 25 Apr 2026 11:08:04 +0900 Subject: [PATCH 7/7] =?UTF-8?q?nginx=20upstream=20keepalive=2010=E2=86=921?= =?UTF-8?q?6=20=ED=8A=9C=EB=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로컬 부하 테스트(wrk 4t/100c/20s, /health_check) 결과 keepalive 16에서 RPS +26%, p99 레이턴시 −28% 개선 확인. 32 이상은 timeout 발생하여 16을 sweet spot으로 채택. Co-Authored-By: Claude Opus 4.7 (1M context) --- nginx.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nginx.conf b/nginx.conf index 82bc0b3..956b2ec 100644 --- a/nginx.conf +++ b/nginx.conf @@ -10,7 +10,7 @@ http { server fastapi-3:8000; server fastapi-4:8000; server fastapi-5:8000; - keepalive 10; + keepalive 16; keepalive_timeout 5; keepalive_requests 1000; }