"""회사 스코프(멀티테넌트) — 회사 소유 자원은 '내 회사 것'만 보이고, 남의 회사 것은 막힌다(보안 회귀 방지). 회사 A 자원을 만들어 두고 회사 B 유저 토큰으로 접근하면 '없음'으로 막히는지 확인한다. 막힘 코드: 견적 1500 / 상품 1300 / 협력사 1400. 견적 하위(세션·상태·결과·카드)도 견적 통해 1500. 견적세팅만 예외 — 회사가 아니라 '유저' 스코프라, 같은 회사라도 다른 유저면 못 본다(1600). """ import uuid from datetime import datetime from sqlalchemy import text from common.enums import QuotationStatus, QuotationType, SessionStatus PAST = datetime(2020, 1, 1) FUTURE = datetime(2999, 1, 1) # ----- 견적 ----- async def test_quotation_hidden_across_company(client, auth_headers, other_company_id, db_engine): """검증: 회사A 견적을 A·B 유저가 각각 단건 조회. 기대결과: A는 success=True / B는 code=1500(없는 것처럼 막힘).""" ha = await auth_headers("qA") qt = await _seed_quotation(db_engine, await _user_id(db_engine, "qA")) assert (await client.get(f"/v1/quotation/{qt}", headers=ha)).json()["result"]["success"] is True hb = await auth_headers("qB", other_company_id) assert (await client.get(f"/v1/quotation/{qt}", headers=hb)).json()["result"]["code"] == 1500 async def test_quotation_list_is_company_scoped(client, auth_headers, other_company_id, db_engine): """검증: 회사A만 견적을 가진 상태에서 A·B 유저가 목록 조회. 기대결과: A 목록 total≥1 / B 목록 total=0.""" ha = await auth_headers("qlA") await _seed_quotation(db_engine, await _user_id(db_engine, "qlA"), number="Q-LIST-A") assert (await client.get("/v1/quotation/list", headers=ha)).json()["total"] >= 1 hb = await auth_headers("qlB", other_company_id) assert (await client.get("/v1/quotation/list", headers=hb)).json()["total"] == 0 async def test_quotation_subresources_hidden_across_company(client, auth_headers, other_company_id, db_engine): """검증: 회사A 견적의 하위자원(세션·상태·결과·카드)을 회사B 유저가 조회. 기대결과: 넷 다 code=1500 으로 막힘 (같은 견적을 A 는 정상 조회).""" ha = await auth_headers("qsA") qt = await _seed_quotation(db_engine, await _user_id(db_engine, "qsA"), number="Q-SUB") hb = await auth_headers("qsB", other_company_id) for path in (f"/v1/quotation/{qt}/sessions", f"/v1/quotation/{qt}/status", f"/v1/quotation/{qt}/result", f"/v1/quotation/{qt}/cards"): assert (await client.get(path, headers=hb)).json()["result"]["code"] == 1500, path assert (await client.get(f"/v1/quotation/{qt}/status", headers=ha)).json()["result"]["success"] is True # ----- 상품(item) ----- async def test_item_hidden_across_company(client, auth_headers, other_company_id): """검증: 회사A 상품을 회사B 유저가 목록·단건 조회. 기대결과: 목록 total=0, 단건 code=1300(ITEM_NOT_FOUND).""" ha = await auth_headers("iA") a_item = (await client.post("/v1/item/create", json={"name": "A상품"}, headers=ha)).json()["item"]["item_id"] hb = await auth_headers("iB", other_company_id) assert (await client.get("/v1/item/list", headers=hb)).json()["total"] == 0 assert (await client.get(f"/v1/item/{a_item}", headers=hb)).json()["result"]["code"] == 1300 # ----- 협력사(supplier) ----- async def test_supplier_hidden_across_company(client, auth_headers, other_company_id): """검증: 회사A 협력사를 회사B 유저가 목록·단건 조회. 기대결과: 목록 total=0, 단건 code=1400(SUPPLIER_NOT_FOUND).""" ha = await auth_headers("sA") a_sup = (await client.post("/v1/supplier/create", json={"name": "A협력사", "code": "SA"}, headers=ha)).json()["supplier"]["supplier_id"] hb = await auth_headers("sB", other_company_id) assert (await client.get("/v1/supplier/list", headers=hb)).json()["total"] == 0 assert (await client.get(f"/v1/supplier/{a_sup}", headers=hb)).json()["result"]["code"] == 1400 # ----- 대시보드 ----- async def test_dashboard_is_company_scoped(client, auth_headers, other_company_id, db_engine): """검증: 회사A만 진행중 견적을 보유. A·B 유저가 각각 대시보드 요약 조회. 기대결과: A 는 company.in_progress≥1 / B 는 0 (타사 견적이 내 회사 집계에 안 섞임).""" ha = await auth_headers("dA") await _seed_quotation(db_engine, await _user_id(db_engine, "dA"), number="Q-DASH") assert (await client.get("/v1/dashboard/summary", headers=ha)).json()["company"]["in_progress"] >= 1 hb = await auth_headers("dB", other_company_id) assert (await client.get("/v1/dashboard/summary", headers=hb)).json()["company"]["in_progress"] == 0 # ----- 견적세팅(회사 아님 — '유저' 스코프) ----- async def test_quotation_setting_is_user_scoped(client, auth_headers): """검증: 유저A 견적세팅을 '같은 회사 다른 유저' B 가 목록/수정 시도. 기대결과: B 목록엔 안 보이고(total=0), 수정은 code=1600(내 소유 아님) — 견적세팅은 유저 단위.""" ha = await auth_headers("stA") a_setting = (await client.post( "/v1/quotation-setting/create", json={"target_margin_rate": 0.15}, headers=ha )).json()["setting"]["qt_setting_id"] hb = await auth_headers("stB") # 같은 회사(company_id 기본), 다른 유저 assert (await client.get("/v1/quotation-setting/list", headers=hb)).json()["total"] == 0 r = await client.patch(f"/v1/quotation-setting/update/{a_setting}", json={"target_margin_rate": 0.2}, headers=hb) assert r.json()["result"]["code"] == 1600 # ===== 헬퍼 (위 테스트들이 쓰는 도우미) ===== async def _user_id(engine, login_id): """auth_headers 로 시드된 유저의 user_id.""" async with engine.begin() as conn: return (await conn.execute( text("SELECT user_id FROM users WHERE id = :id"), {"id": login_id} )).scalar_one() async def _seed_quotation(engine, user_id, *, number="Q-SCOPE"): """작성자=user_id 인 견적 1건 + 세션 1건 시드(진행중).""" qt_id = uuid.uuid4() async with engine.begin() as conn: await conn.execute( text( "INSERT INTO quotations " "(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, " " round, iteration, start_time, end_time, deleted) VALUES " "(:qt_id, :uid, :setting, :version, '견적A', :number, :type, :status, 1, 0, :past, :future, false)" ), {"qt_id": qt_id, "uid": user_id, "setting": uuid.uuid4(), "version": uuid.uuid4(), "number": number, "type": QuotationType.REQUOTE.value, "status": QuotationStatus.IN_PROGRESS.value, "past": PAST, "future": FUTURE}, ) await conn.execute( text( "INSERT INTO sessions " "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " " target_price, status, end_time) VALUES " "(:sid, :qt, :item, :sup, :number, 1, :type, 0, :st, :future)" ), {"sid": uuid.uuid4(), "qt": qt_id, "item": uuid.uuid4(), "sup": uuid.uuid4(), "number": number, "type": QuotationType.REQUOTE.value, "st": SessionStatus.CREATED.value, "future": FUTURE}, ) return qt_id