o2o-negosium-original/negodata/backend/tests/test_company_scope.py
Mina Choi 82076e138e [test] negodata: 백엔드 테스트 스위트 구축 + 공통 픽스처(conftest) 정비
- test DB 세션마다 자동 create/drop (팀원은 Postgres만 있으면 pytest 한 방)
- auth_headers 시드 픽스처(무인증 /auth/create 제거 대응) + other_company_id
- 커버: 회사 스코프(견적·상품·협력사·대시보드·세팅), 견적 마감 재견적 O/X + 알림,
  견적 생성·목표가, 알림함 읽기, 회사유저 OWNER 게이팅, 기존 파일 검증/기대결과 주석 정비

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:06:55 +09:00

141 lines
7.4 KiB
Python

"""회사 스코프(멀티테넌트) — 회사 소유 자원은 '내 회사 것'만 보이고, 남의 회사 것은 막힌다(보안 회귀 방지).
회사 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