- 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>
141 lines
7.4 KiB
Python
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
|