diff --git a/README.md b/README.md index a7d81e7..4bf2752 100644 --- a/README.md +++ b/README.md @@ -92,3 +92,7 @@ Dockerfile 提供 test、runtime 两个目标;Gitea Actions 在 main 推送后 本地可运行 `uv run adcloud-events`,Cassandra 模式还需 `uv run adcloud-profiles`。 完整目录、API、配置和兼容说明见 [事件注册表](docs/events.md)。 开发者控制台已加入事件字典页面;客户账号登录和角色授权尚未接入,目前使用应用范围管理凭据。 + +## 广告主控制台 + +新增独立 `/v1/advertiser` 注册登录、主子账户和项目 API。广告主身份与终端流量用户 identity 分离;详细接口、部署要求和首版边界见 [广告主 API](docs/advertiser-console.md)。广告主认证不依赖 IDENTITY_SECRET,应用启动会初始化共享数据库。 diff --git a/compose.yml b/compose.yml index 0c67f9e..7c59deb 100644 --- a/compose.yml +++ b/compose.yml @@ -14,7 +14,7 @@ x-app: &app CASSANDRA_HOSTS: cassandra CASSANDRA_TLS: 'false' COOKIE_SECURE: 'false' - CORS_ORIGINS: ${DEPLOY_CORS_ORIGINS:-http://localhost:3000,http://localhost:5173,http://localhost:5174} + CORS_ORIGINS: ${DEPLOY_CORS_ORIGINS:-http://localhost:3000,http://localhost:5173,http://localhost:5174,http://47.76.92.159:18081} depends_on: &app-deps postgres: {condition: service_healthy} cassandra-init: {condition: service_completed_successfully} @@ -78,6 +78,7 @@ services: profiles: [ci] depends_on: *app-deps environment: + ADCLOUD_REQUIRE_INTEGRATION: 'true' ADCLOUD_TEST_POSTGRES_URL: postgresql+psycopg://adcloud:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@postgres:5432/adcloud ADCLOUD_TEST_CASSANDRA_HOSTS: cassandra ADCLOUD_TEST_CASSANDRA_KEYSPACE: adcloud diff --git a/docs/advertiser-console.md b/docs/advertiser-console.md new file mode 100644 index 0000000..cb8c98e --- /dev/null +++ b/docs/advertiser-console.md @@ -0,0 +1,28 @@ +# 广告主控制台 API + +访客 `identity` 代表产生广告流量的产品终端用户。广告主使用独立 `advertiser_*` 表、密码与会话,不执行 stitch,不依赖 IDENTITY_SECRET。 + +- `POST /v1/advertiser/register`:email、password(10–128 字符)、name、company;原子创建用户、主账户、默认子账户。 +- `POST /v1/advertiser/login`、`POST /logout`、`GET /me`。 +- `GET/POST /v1/advertiser/accounts`:创建时 parent_id 为空表示主账户,否则必须是本人主账户。 +- `PUT /v1/advertiser/accounts/{id}`:修改 name、company、industry,不允许换归属。 +- `GET/POST /v1/advertiser/accounts/{id}/projects`:只允许子账户创建项目。 +- `PUT /v1/advertiser/accounts/{id}/projects/{project_id}`:校验账户所有权和项目归属。 + +主账户 → 子账户 → 项目。没有第三层账户。当前每个主子账户只有注册用户本人有权限;成员与角色授权后续实现。 + +密码使用独立随机盐 + scrypt,会话为随机 256-bit token,数据库仅保存 SHA-256 摘要,24 小时失效,退出立即撤销。浏览器 HttpOnly / SameSite=Lax Cookie;Secure 由 COOKIE_SECURE 控制。写操作复用显式 Origin 校验,生产应仅允许实际控制台来源。登录 / 注册按客户端 IP + 邮箱在数据库中限频(十分钟十次);生产还应配合入口级全局限频,防止轮换邮箱/IP 绕过。 + +广告主接口共用 DATABASE_URL 数据库和事务锁,但身份完全独立。即使没有配置访客身份密钥,启动也会初始化数据库;访客接口仍返回 503。新增表采用现有 additive create_all 初始化,不修改已存在的 identity 表结构。 + +当前不提供邮箱验证、密码找回、成员协作、资质审批、预算扣款或广告投放。项目状态只有草稿 / 已暂停。所有金额表示配置值,不是资金账本。上线前必须补齐业务审核和资金域。 + +验证:`uv run pytest tests/test_advertiser.py` 覆盖会话、身份隔离、主子层级、跨用户访问、项目修改、日期预算校验、Origin 和限频。 + +## URL 导航与服务器 CI + +新增 `GET /v1/advertiser/accounts/{account_id}/projects/{project_id}`,支持项目详情直达和刷新;校验登录用户、投放子账户和项目归属。项目配置包含推广产品、转化目标和可选监测链接,六个页面步骤共用一个持久化项目。 + +广告主 API 测试分别在 SQLite 和真实 PostgreSQL 上执行;PostgreSQL 使用每个测试独立的随机 schema,结束后只清理该 schema。CI integration 容器设置 `ADCLOUD_REQUIRE_INTEGRATION=true`,任何跳过的测试都会令集成阶段失败,避免数据库缺失时仍显示成功。 + +测试环境默认 CORS 增加 `http://47.76.92.159:18081`,用于同 runner 部署的广告主前端。保持既有开发者端来源;生产地址通过 `DEPLOY_CORS_ORIGINS` 覆盖。 diff --git a/src/adcloud_server/api/v1/advertiser.py b/src/adcloud_server/api/v1/advertiser.py new file mode 100644 index 0000000..cf75dc0 --- /dev/null +++ b/src/adcloud_server/api/v1/advertiser.py @@ -0,0 +1,419 @@ +"""Advertiser console accounts are separate from tracked visitor identities.""" + +import hashlib +import hmac +import secrets +import time +from typing import Annotated, Literal +from uuid import uuid4 + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator +from sqlalchemy import Column, Integer, String, Table, select + +from adcloud_server.database import Database, document_type, metadata +from adcloud_server.dependencies import allowed_browser_origin + + +def private_response(response: Response): + response.headers["Cache-Control"] = "no-store" + + +router = APIRouter( + dependencies=[Depends(allowed_browser_origin), Depends(private_response)] +) +users = Table( + "advertiser_users", + metadata, + Column("id", String(36), primary_key=True), + Column("email", String(320), nullable=False, unique=True), + Column("password", String(256), nullable=False), + Column("name", String(100), nullable=False), +) +sessions = Table( + "advertiser_sessions", + metadata, + Column("digest", String(64), primary_key=True), + Column("user_id", String(36), nullable=False), + Column("expires", Integer, nullable=False), +) +accounts = Table( + "advertiser_accounts", + metadata, + Column("id", String(36), primary_key=True), + Column("owner_id", String(36), nullable=False), + Column("parent_id", String(36), nullable=True), + Column("document", document_type, nullable=False), +) +projects = Table( + "advertiser_projects", + metadata, + Column("id", String(36), primary_key=True), + Column("account_id", String(36), nullable=False), + Column("document", document_type, nullable=False), +) +attempts = Table( + "advertiser_auth_attempts", + metadata, + Column("key", String(64), primary_key=True), + Column("count", Integer, nullable=False), + Column("expires", Integer, nullable=False), +) +COOKIE = "adcloud_advertiser_session" + + +class Input(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + +class Login(Input): + email: EmailStr + password: str = Field(min_length=10, max_length=128) + + +class Register(Login): + name: str = Field(min_length=1, max_length=100) + company: str = Field(min_length=2, max_length=100) + + +class Account(Input): + name: str = Field(min_length=2, max_length=100) + company: str = Field(min_length=2, max_length=100) + industry: str = Field(default="其他", min_length=1, max_length=50) + + +class CreateAccount(Account): + parent_id: str | None = None + + +class Project(Input): + name: str = Field(min_length=2, max_length=100) + objective: Literal["销售线索", "应用推广", "电商", "商品", "原生互动"] = "销售线索" + ad_type: Literal["通投广告", "搜索广告"] = "通投广告" + scene: Literal["短视频与图文", "直播"] = "短视频与图文" + budget: int = Field(ge=100, le=10000000) + bid: float = Field(gt=0, le=100000, allow_inf_nan=False) + audience: Literal["智能推荐", "自定义"] = "智能推荐" + region: str = Field(default="全国", min_length=1, max_length=100) + start_date: str = Field(pattern=r"^\d{4}-\d{2}-\d{2}$") + end_date: str = Field(default="", max_length=10) + product_name: str = Field(default="", max_length=100) + conversion_goal: Literal["获取线索", "完成购买", "应用安装", "内容互动"] = ( + "获取线索" + ) + tracking_url: str = Field(default="", max_length=2048) + landing_url: str = Field(default="", max_length=2048) + status: Literal["草稿", "已暂停"] = "草稿" + + @model_validator(mode="after") + def valid_schedule(self): + from datetime import date + from urllib.parse import urlsplit + + start = date.fromisoformat(self.start_date) + if self.end_date and date.fromisoformat(self.end_date) < start: + raise ValueError("结束日期不能早于开始日期") + for value in (self.landing_url, self.tracking_url): + if value: + url = urlsplit(value) + if url.scheme not in ("http", "https") or not url.netloc: + raise ValueError("链接须为完整的 HTTP/HTTPS 地址") + return self + + +def password_hash(password, salt): + return hashlib.scrypt( + password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1 + ).hex() + + +def throttle(db, request, email): + # Database-backed limits work across workers; never trust forwarded IP headers. + key = hashlib.sha256( + f"{request.client.host if request.client else 'unknown'}:{email}".encode() + ).hexdigest() + now = int(time.time()) + blocked = False + with db.transaction() as conn: + conn.execute(attempts.delete().where(attempts.c.expires < now)) + row = ( + conn.execute(select(attempts).where(attempts.c.key == key)) + .mappings() + .first() + ) + if row and row["count"] >= 10: + blocked = True + elif row: + conn.execute( + attempts.update() + .where(attempts.c.key == key) + .values(count=row["count"] + 1) + ) + else: + conn.execute(attempts.insert().values(key=key, count=1, expires=now + 600)) + if blocked: + raise HTTPException(429, "尝试次数过多,请十分钟后重试") + + +def advertiser_database(request: Request): + return request.app.state.advertiser_database + + +DatabaseDependency = Annotated[Database, Depends(advertiser_database)] + + +def current_user(request: Request, db: DatabaseDependency): + digest = hashlib.sha256(request.cookies.get(COOKIE, "").encode()).hexdigest() + with db.transaction() as conn: + row = ( + conn.execute( + select(users.c.id, users.c.name, users.c.email) + .join(sessions, sessions.c.user_id == users.c.id) + .where( + sessions.c.digest == digest, sessions.c.expires > int(time.time()) + ) + ) + .mappings() + .first() + ) + if not row: + raise HTTPException(401, "请登录广告主账户") + return dict(row) + + +CurrentUser = Annotated[dict, Depends(current_user)] + + +def session(conn, user_id, response, request): + token = secrets.token_urlsafe(32) + conn.execute(sessions.delete().where(sessions.c.expires < int(time.time()))) + conn.execute( + sessions.insert().values( + digest=hashlib.sha256(token.encode()).hexdigest(), + user_id=user_id, + expires=int(time.time()) + 86400, + ) + ) + response.set_cookie( + COOKIE, + token, + httponly=True, + secure=request.app.state.settings.cookie_secure, + samesite="lax", + max_age=86400, + path="/v1/advertiser", + ) + response.headers["Cache-Control"] = "no-store" + + +def owned(conn, account_id, user): + row = ( + conn.execute( + select(accounts).where( + accounts.c.id == account_id, accounts.c.owner_id == user["id"] + ) + ) + .mappings() + .first() + ) + if not row: + raise HTTPException(404, "广告账户不存在") + return row + + +@router.post("/register", status_code=201) +def register( + body: Register, request: Request, response: Response, db: DatabaseDependency +): + email = str(body.email).lower() + throttle(db, request, email) + salt = secrets.token_hex(16) + hashed = salt + ":" + password_hash(body.password, salt) + user_id = str(uuid4()) + with db.transaction() as conn: + if conn.execute(select(users.c.id).where(users.c.email == email)).first(): + raise HTTPException(409, "该邮箱已注册") + conn.execute( + users.insert().values( + id=user_id, email=email, name=body.name, password=hashed + ) + ) + parent_id = str(uuid4()) + conn.execute( + accounts.insert().values( + id=parent_id, + owner_id=user_id, + document={ + "name": body.company, + "company": body.company, + "industry": "其他", + }, + ) + ) + conn.execute( + accounts.insert().values( + id=str(uuid4()), + owner_id=user_id, + parent_id=parent_id, + document={ + "name": body.company + " · 默认投放账户", + "company": body.company, + "industry": "其他", + }, + ) + ) + session(conn, user_id, response, request) + return {"id": user_id, "name": body.name, "email": email} + + +@router.post("/login") +def login(body: Login, request: Request, response: Response, db: DatabaseDependency): + email = str(body.email).lower() + throttle(db, request, email) + with db.transaction() as conn: + row = ( + conn.execute(select(users).where(users.c.email == email)).mappings().first() + ) + salt, expected = row["password"].split(":") if row else ("00" * 16, "00" * 64) + valid = hmac.compare_digest(password_hash(body.password, salt), expected) + if not row or not valid: + raise HTTPException(401, "邮箱或密码不正确") + session(conn, row["id"], response, request) + return {key: row[key] for key in ("id", "name", "email")} + + +@router.post("/logout", status_code=204) +def logout(request: Request, response: Response, db: DatabaseDependency): + with db.transaction() as conn: + conn.execute( + sessions.delete().where( + sessions.c.digest + == hashlib.sha256(request.cookies.get(COOKIE, "").encode()).hexdigest() + ) + ) + response.delete_cookie(COOKIE, path="/v1/advertiser") + + +@router.get("/me") +def me(response: Response, user: CurrentUser): + response.headers["Cache-Control"] = "no-store" + return user + + +@router.get("/accounts") +def list_accounts(db: DatabaseDependency, user: CurrentUser): + with db.transaction() as conn: + return [ + {"id": row.id, "parent_id": row.parent_id, **row.document} + for row in conn.execute( + select(accounts).where(accounts.c.owner_id == user["id"]) + ) + ] + + +@router.post("/accounts", status_code=201) +def create_account(body: CreateAccount, db: DatabaseDependency, user: CurrentUser): + account_id = str(uuid4()) + with db.transaction() as conn: + if body.parent_id: + parent = owned(conn, body.parent_id, user) + if parent["parent_id"]: + raise HTTPException(422, "子账户下不能继续创建账户") + conn.execute( + accounts.insert().values( + id=account_id, + owner_id=user["id"], + parent_id=body.parent_id, + document=body.model_dump(exclude={"parent_id"}), + ) + ) + return {"id": account_id, **body.model_dump()} + + +@router.put("/accounts/{account_id}") +def update_account( + account_id: str, + body: Account, + db: DatabaseDependency, + user: CurrentUser, +): + with db.transaction() as conn: + owned(conn, account_id, user) + conn.execute( + accounts.update() + .where(accounts.c.id == account_id) + .values(document=body.model_dump()) + ) + return {"id": account_id, **body.model_dump()} + + +@router.get("/accounts/{account_id}/projects") +def list_projects(account_id: str, db: DatabaseDependency, user: CurrentUser): + with db.transaction() as conn: + owned(conn, account_id, user) + return [ + {"id": row.id, **row.document} + for row in conn.execute( + select(projects).where(projects.c.account_id == account_id) + ) + ] + + +@router.post("/accounts/{account_id}/projects", status_code=201) +def create_project( + account_id: str, + body: Project, + db: DatabaseDependency, + user: CurrentUser, +): + project_id = str(uuid4()) + with db.transaction() as conn: + account = owned(conn, account_id, user) + if not account["parent_id"]: + raise HTTPException(422, "请在子账户中创建项目") + conn.execute( + projects.insert().values( + id=project_id, account_id=account_id, document=body.model_dump() + ) + ) + return {"id": project_id, **body.model_dump()} + + +@router.put("/accounts/{account_id}/projects/{project_id}") +def update_project( + account_id: str, + project_id: str, + body: Project, + db: DatabaseDependency, + user: CurrentUser, +): + with db.transaction() as conn: + owned(conn, account_id, user) + result = conn.execute( + projects.update() + .where(projects.c.id == project_id, projects.c.account_id == account_id) + .values(document=body.model_dump()) + ) + if not result.rowcount: + raise HTTPException(404, "项目不存在") + return {"id": project_id, **body.model_dump()} + + +@router.get("/accounts/{account_id}/projects/{project_id}") +def get_project( + account_id: str, project_id: str, db: DatabaseDependency, user: CurrentUser +): + with db.transaction() as conn: + owned(conn, account_id, user) + row = ( + conn.execute( + select(projects).where( + projects.c.id == project_id, projects.c.account_id == account_id + ) + ) + .mappings() + .first() + ) + if not row: + raise HTTPException(404, "项目不存在或没有访问权限") + return {"id": row["id"], **row["document"]} diff --git a/src/adcloud_server/api/v1/router.py b/src/adcloud_server/api/v1/router.py index 55ec5aa..eb543e6 100644 --- a/src/adcloud_server/api/v1/router.py +++ b/src/adcloud_server/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from adcloud_server.api.v1 import ads, events, identity, registry, users +from adcloud_server.api.v1 import ads, advertiser, events, identity, registry, users router = APIRouter(prefix="/v1") router.include_router(events.router, prefix="/events", tags=["events"]) @@ -9,3 +9,5 @@ router.include_router(ads.router, prefix="/ads", tags=["ads"]) router.include_router(identity.router, prefix="/identity", tags=["identity"]) router.include_router(registry.router, prefix="/registry", tags=["registry"]) + +router.include_router(advertiser.router, prefix="/advertiser", tags=["advertiser"]) diff --git a/src/adcloud_server/main.py b/src/adcloud_server/main.py index eb41d24..393dc64 100644 --- a/src/adcloud_server/main.py +++ b/src/adcloud_server/main.py @@ -20,9 +20,10 @@ def create_app(settings: Settings | None = None) -> FastAPI: projection = None application.state.identity_service = None try: + database = Database(settings.database_url, settings.tenant_id) + database.initialize() + application.state.advertiser_database = database if settings.identity_secret: - database = Database(settings.database_url, settings.tenant_id) - database.initialize() projection = create_profile_store(settings) application.state.identity_service = IdentityService( settings, database, projection diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..58ddda4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +"""The CI integration phase must never silently skip unavailable databases.""" + +import os + +import pytest + + +@pytest.hookimpl(trylast=True) +def pytest_sessionfinish(session, exitstatus): + if os.getenv("ADCLOUD_REQUIRE_INTEGRATION") != "true": + return + reporter = session.config.pluginmanager.get_plugin("terminalreporter") + if reporter and reporter.stats.get("skipped"): + reporter.write_sep("=", "Integration CI forbids skipped tests") + session.exitstatus = pytest.ExitCode.TESTS_FAILED diff --git a/tests/test_advertiser.py b/tests/test_advertiser.py new file mode 100644 index 0000000..313ee48 --- /dev/null +++ b/tests/test_advertiser.py @@ -0,0 +1,187 @@ +import os +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, select, text +from sqlalchemy.engine import make_url + +from adcloud_server.database import profiles +from adcloud_server.main import create_app +from adcloud_server.settings import Settings + +PREFIX = "/v1/advertiser" + + +@pytest.fixture(params=["sqlite", "postgres"]) +def client(tmp_path, request): + engine = None + schema = None + url = f"sqlite:///{tmp_path}/app.db" + if request.param == "postgres": + base = os.getenv("ADCLOUD_TEST_POSTGRES_URL") + if not base: + pytest.skip("No PostgreSQL integration URL configured") + schema = "advertiser_test_" + uuid4().hex + engine = create_engine(base) + with engine.begin() as conn: + conn.execute(text(f'CREATE SCHEMA "{schema}"')) + url = ( + make_url(base) + .update_query_dict({"options": f"-csearch_path={schema}"}) + .render_as_string(hide_password=False) + ) + try: + # Advertiser authentication also works without the visitor identity secret. + with TestClient( + create_app(Settings(database_url=url, cookie_secure=False)) + ) as test_client: + yield test_client + finally: + if engine is not None: + # Only this fixture's random schema in the disposable CI database. + with engine.begin() as conn: + conn.execute(text(f'DROP SCHEMA "{schema}" CASCADE')) + engine.dispose() + + +def register(client, email="owner@example.com"): + response = client.post( + PREFIX + "/register", + json={ + "email": email, + "password": "long-password-123", + "name": "测试广告主", + "company": "测试企业", + }, + ) + assert response.status_code == 201, response.text + return response + + +def project(): + return {"name": "秋季推广", "budget": 300, "bid": 30, "start_date": "2026-09-16"} + + +def test_registration_session_and_identity_separation(client): + response = register(client) + assert "HttpOnly" in response.headers["set-cookie"] + assert "password" not in response.json() + assert client.get(PREFIX + "/me").status_code == 200 + with client.app.state.advertiser_database.transaction() as conn: + assert conn.execute(select(profiles)).first() is None + assert client.post(PREFIX + "/logout").status_code == 204 + assert client.get(PREFIX + "/me").status_code == 401 + assert ( + client.post( + PREFIX + "/login", + json={"email": "owner@example.com", "password": "wrong-password"}, + ).status_code + == 401 + ) + assert ( + client.post( + PREFIX + "/login", + json={"email": "OWNER@example.com", "password": "long-password-123"}, + ).status_code + == 200 + ) + assert ( + client.post( + PREFIX + "/register", + json={ + "email": "owner@example.com", + "password": "long-password-123", + "name": "重复", + "company": "重复", + }, + ).status_code + == 409 + ) + + +def test_hierarchy_projects_and_isolation(client): + register(client) + accounts = client.get(PREFIX + "/accounts").json() + parent = next(a for a in accounts if not a["parent_id"]) + child = next(a for a in accounts if a["parent_id"]) + assert child["parent_id"] == parent["id"] + root_path = PREFIX + "/accounts/" + parent["id"] + path = PREFIX + "/accounts/" + child["id"] + assert client.post(root_path + "/projects", json=project()).status_code == 422 + assert ( + client.post( + PREFIX + "/accounts", + json={"parent_id": child["id"], "name": "三级账户", "company": "公司"}, + ).status_code + == 422 + ) + created = client.post(path + "/projects", json=project()) + assert created.status_code == 201 + detail = client.get(path + "/projects/" + created.json()["id"]) + assert detail.status_code == 200 + assert detail.json()["name"] == project()["name"] + item = created.json() + item.pop("id") + item["name"] = "修改后的项目" + project_path = path + "/projects/" + created.json()["id"] + assert client.put(project_path, json=item).status_code == 200 + assert client.get(path + "/projects").json()[0]["name"] == "修改后的项目" + assert ( + client.post(path + "/projects", json={**project(), "budget": 99}).status_code + == 422 + ) + assert ( + client.post( + path + "/projects", json={**project(), "end_date": "2026-01-01"} + ).status_code + == 422 + ) + assert ( + client.post( + path + "/projects", json={**project(), "landing_url": "javascript:alert(1)"} + ).status_code + == 422 + ) + client.post(PREFIX + "/logout") + register(client, "other@example.com") + assert client.get(path + "/projects").status_code == 404 + assert client.get(project_path).status_code == 404 + assert client.put(project_path, json=item).status_code == 404 + assert ( + client.put(path, json={"name": "越权修改", "company": "公司"}).status_code + == 404 + ) + assert ( + client.post( + PREFIX + "/accounts", + json={"name": "越权子账户", "company": "公司", "parent_id": parent["id"]}, + ).status_code + == 404 + ) + + +def test_origin_and_throttle(client): + assert ( + client.post( + PREFIX + "/login", + headers={"Origin": "https://evil.example"}, + json={"email": "owner@example.com", "password": "long-password"}, + ).status_code + == 403 + ) + for _ in range(10): + assert ( + client.post( + PREFIX + "/login", + json={"email": "unknown@example.com", "password": "wrong-password"}, + ).status_code + == 401 + ) + assert ( + client.post( + PREFIX + "/login", + json={"email": "unknown@example.com", "password": "wrong-password"}, + ).status_code + == 429 + ) diff --git a/tests/test_identity.py b/tests/test_identity.py index a923f0b..b695834 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -412,7 +412,9 @@ def test_missing_configuration_disables_identity_but_keeps_gateway_available(tmp ).status_code == 503 ) - assert not (tmp_path / "unused.db").exists() + # Advertiser auth initializes storage independently of visitor identity. + assert (tmp_path / "unused.db").exists() + assert client.get("/v1/advertiser/me").status_code == 401 def test_secure_cookie_configuration():