feat(server): mint API + Postgres schema + manual adapter (PR 1)
Source-agnostic license issuance service. FastAPI app fronts a Postgres `licenses` table; the only currently-wired source is `manual` (operator mints via /internal/mint). Gumroad webhook adapter lands in PR 2. Key design points: - Signing reuses src/license/crypto.py via a COPY into the image (single source of truth — blobs minted server-side verify against the same embedded pubkey on the buyer's machine). - Source adapter Protocol (app/adapters/base.py) is the seam for Gumroad / Lemon Squeezy / Stripe in later PRs; Mint API speaks only SaleEvent / RefundEvent. - (source, source_order_id) UNIQUE composite gives idempotent webhook retries without double-mint. - JSONB type uses with_variant(JSON, 'sqlite') so the same models drive both Postgres prod and SQLite tests (no testcontainers dep). - Bearer-token auth on /internal/*; the IP-loopback guard was removed after the docker bridge made it fight legitimate prod traffic (nginx defense + Bearer remain). - Secrets resolved via *_FILE env vars pointing at /run/secrets/<name>, so passwords never appear in `docker inspect`. 21 unit tests (SQLite in-memory, StaticPool) plus a real-Postgres docker-compose smoke test in server/scripts/smoke.sh that builds the image, runs the alembic migration, mints a license, verifies the signature against the host dev pubkey, and checks the DB row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
16
server/.dockerignore
Normal file
16
server/.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
**/__pycache__
|
||||||
|
**/*.pyc
|
||||||
|
**/.pytest_cache
|
||||||
|
**/.mypy_cache
|
||||||
|
**/.ruff_cache
|
||||||
|
.git
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
docs
|
||||||
|
landing
|
||||||
|
marketing
|
||||||
|
samples
|
||||||
|
test-cases
|
||||||
|
tests
|
||||||
|
logs
|
||||||
|
build
|
||||||
37
server/Dockerfile
Normal file
37
server/Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# syntax=docker/dockerfile:1.6
|
||||||
|
FROM python:3.12-slim AS base
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
libpq5 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN useradd --system --create-home --shell /usr/sbin/nologin --uid 10001 app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY server/requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install -r /app/requirements.txt
|
||||||
|
|
||||||
|
# Reused crypto / schema logic from the desktop app — single source of truth.
|
||||||
|
COPY src/license /app/datatools_license
|
||||||
|
|
||||||
|
COPY server/app /app/app
|
||||||
|
COPY server/alembic /app/alembic
|
||||||
|
COPY server/alembic.ini /app/alembic.ini
|
||||||
|
|
||||||
|
RUN chown -R app:app /app
|
||||||
|
USER app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
|
||||||
|
CMD curl --fail --silent --show-error http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips", "*"]
|
||||||
38
server/alembic.ini
Normal file
38
server/alembic.ini
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url =
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
46
server/alembic/env.py
Normal file
46
server/alembic/env.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Alembic environment.
|
||||||
|
|
||||||
|
Reads the runtime database URL from ``app.db`` (which resolves the
|
||||||
|
password from the secrets file), so ``alembic upgrade head`` Just
|
||||||
|
Works inside the API container with no extra env wiring.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
from app.db import Base, engine
|
||||||
|
from app import models # noqa: F401 — imported for side-effect of registering models
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=str(engine.url),
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
with engine.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
26
server/alembic/script.py.mako
Normal file
26
server/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
80
server/alembic/versions/0001_initial.py
Normal file
80
server/alembic/versions/0001_initial.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Initial schema — licenses + gumroad_events.
|
||||||
|
|
||||||
|
Revision ID: 0001_initial
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-05-14
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0001_initial"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"licenses",
|
||||||
|
sa.Column("license_key", sa.String(), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(), nullable=False),
|
||||||
|
sa.Column("email", sa.String(), nullable=False),
|
||||||
|
sa.Column("tier", sa.String(), nullable=False),
|
||||||
|
sa.Column("issued_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("blob", sa.String(), nullable=False),
|
||||||
|
sa.Column("source", sa.String(), nullable=False),
|
||||||
|
sa.Column("source_order_id", sa.String(), nullable=True),
|
||||||
|
sa.Column("promotion", sa.String(), nullable=True),
|
||||||
|
sa.Column("amount_paid", sa.Numeric(10, 2), nullable=True),
|
||||||
|
sa.Column("currency", sa.String(length=3), server_default=sa.text("'USD'"), nullable=True),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("notes", sa.String(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.UniqueConstraint("source", "source_order_id", name="uq_licenses_source_order"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_licenses_email_lower",
|
||||||
|
"licenses",
|
||||||
|
[sa.text("lower(email)")],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_licenses_expires_active",
|
||||||
|
"licenses",
|
||||||
|
["expires_at"],
|
||||||
|
postgresql_where=sa.text("revoked_at IS NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"gumroad_events",
|
||||||
|
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("received_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("event_type", sa.String(), nullable=False),
|
||||||
|
sa.Column("order_id", sa.String(), nullable=True),
|
||||||
|
sa.Column("raw_payload", postgresql.JSONB(), nullable=False),
|
||||||
|
sa.Column("processed", sa.Boolean(), server_default=sa.text("false"), nullable=False),
|
||||||
|
sa.Column("error", sa.String(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_gumroad_events_order_id", "gumroad_events", ["order_id"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_gumroad_events_unprocessed",
|
||||||
|
"gumroad_events",
|
||||||
|
["received_at"],
|
||||||
|
postgresql_where=sa.text("processed = false"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_gumroad_events_unprocessed", table_name="gumroad_events")
|
||||||
|
op.drop_index("ix_gumroad_events_order_id", table_name="gumroad_events")
|
||||||
|
op.drop_table("gumroad_events")
|
||||||
|
op.drop_index("ix_licenses_expires_active", table_name="licenses")
|
||||||
|
op.drop_index("ix_licenses_email_lower", table_name="licenses")
|
||||||
|
op.drop_table("licenses")
|
||||||
0
server/app/__init__.py
Normal file
0
server/app/__init__.py
Normal file
0
server/app/adapters/__init__.py
Normal file
0
server/app/adapters/__init__.py
Normal file
71
server/app/adapters/base.py
Normal file
71
server/app/adapters/base.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""Source-adapter interface.
|
||||||
|
|
||||||
|
The Mint API speaks only the normalized event types defined here.
|
||||||
|
Each storefront has its own adapter that:
|
||||||
|
|
||||||
|
- Verifies the storefront's webhook signature in its native format.
|
||||||
|
- Parses the storefront's payload into a :class:`SaleEvent` or
|
||||||
|
:class:`RefundEvent`.
|
||||||
|
- Maps the storefront's product/variant IDs to a license tier via
|
||||||
|
the per-source config in :mod:`app.adapters.config`.
|
||||||
|
|
||||||
|
Adding a new source (Lemon Squeezy, Stripe, Paddle) is one new
|
||||||
|
module that implements :class:`SourceAdapter`. The Mint API and DB
|
||||||
|
do not change.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any, Optional, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SaleEvent:
|
||||||
|
"""A storefront sale, normalized.
|
||||||
|
|
||||||
|
The Mint API consumes this directly — it never reaches into the
|
||||||
|
raw storefront payload. Anything storefront-specific that's worth
|
||||||
|
keeping is preserved in :attr:`raw_payload` for audit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
source: str # e.g. "gumroad", "manual"
|
||||||
|
source_order_id: Optional[str] # storefront's order ID; None for manual mints
|
||||||
|
buyer_name: str
|
||||||
|
buyer_email: str
|
||||||
|
tier: str # mapped from product/variant
|
||||||
|
years: int = 1
|
||||||
|
promotion: Optional[str] = None
|
||||||
|
amount_paid: Optional[Decimal] = None
|
||||||
|
currency: Optional[str] = "USD"
|
||||||
|
notes: Optional[str] = None
|
||||||
|
raw_payload: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RefundEvent:
|
||||||
|
"""A storefront refund — marks an existing license revoked."""
|
||||||
|
|
||||||
|
source: str
|
||||||
|
source_order_id: str
|
||||||
|
reason: Optional[str] = None
|
||||||
|
raw_payload: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class SourceAdapter(Protocol):
|
||||||
|
"""Interface every storefront adapter implements."""
|
||||||
|
|
||||||
|
source_name: str
|
||||||
|
|
||||||
|
def verify_webhook(self, *, body: bytes, headers: dict[str, str]) -> bool:
|
||||||
|
"""Return True iff the request came from the legitimate storefront."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def parse_sale(self, payload: dict[str, Any]) -> Optional[SaleEvent]:
|
||||||
|
"""Return a :class:`SaleEvent` if *payload* is a sale, else None."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def parse_refund(self, payload: dict[str, Any]) -> Optional[RefundEvent]:
|
||||||
|
"""Return a :class:`RefundEvent` if *payload* is a refund, else None."""
|
||||||
|
...
|
||||||
52
server/app/adapters/manual.py
Normal file
52
server/app/adapters/manual.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Manual adapter — operator-initiated mints (comps, support replacements).
|
||||||
|
|
||||||
|
There is no webhook to verify and no payload to parse: the operator
|
||||||
|
hands us the buyer details directly via the CLI, and we construct a
|
||||||
|
:class:`SaleEvent` from them. ``source='manual'`` separates these
|
||||||
|
rows from storefront-driven mints in the DB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from app.adapters.base import RefundEvent, SaleEvent
|
||||||
|
|
||||||
|
|
||||||
|
class ManualAdapter:
|
||||||
|
source_name = "manual"
|
||||||
|
|
||||||
|
def verify_webhook(self, *, body: bytes, headers: dict[str, str]) -> bool:
|
||||||
|
return False # manual flows never come through webhooks
|
||||||
|
|
||||||
|
def parse_sale(self, payload: dict[str, Any]) -> Optional[SaleEvent]:
|
||||||
|
return self.build_sale(**payload)
|
||||||
|
|
||||||
|
def parse_refund(self, payload: dict[str, Any]) -> Optional[RefundEvent]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def build_sale(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
email: str,
|
||||||
|
tier: str,
|
||||||
|
years: int = 1,
|
||||||
|
promotion: Optional[str] = None,
|
||||||
|
amount_paid: Optional[Decimal] = None,
|
||||||
|
currency: Optional[str] = "USD",
|
||||||
|
notes: Optional[str] = None,
|
||||||
|
) -> SaleEvent:
|
||||||
|
return SaleEvent(
|
||||||
|
source=self.source_name,
|
||||||
|
source_order_id=None,
|
||||||
|
buyer_name=name,
|
||||||
|
buyer_email=email,
|
||||||
|
tier=tier,
|
||||||
|
years=years,
|
||||||
|
promotion=promotion,
|
||||||
|
amount_paid=amount_paid,
|
||||||
|
currency=currency,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
65
server/app/auth.py
Normal file
65
server/app/auth.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""Auth guards for ``/internal/*``.
|
||||||
|
|
||||||
|
Active layer: Bearer token, presented by the operator's CLI and
|
||||||
|
matched against the value in the secrets dir. Token rotation =
|
||||||
|
update the file, restart the container.
|
||||||
|
|
||||||
|
:func:`require_localhost` is preserved but unused by default — it
|
||||||
|
fights the Docker bridge network model (the container sees the
|
||||||
|
gateway IP, not 127.0.0.1, regardless of where traffic originated).
|
||||||
|
Re-enable it only if the API runs in ``network_mode: host``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request, status
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
def require_localhost(request: Request) -> None:
|
||||||
|
"""Reject the request unless the connecting peer is loopback.
|
||||||
|
|
||||||
|
``request.client.host`` reflects the actual TCP peer (the nginx
|
||||||
|
upstream connecting from 127.0.0.1) when ``proxy_set_header`` is
|
||||||
|
used appropriately. We deliberately do NOT trust
|
||||||
|
``X-Forwarded-For`` here — we want the raw peer.
|
||||||
|
"""
|
||||||
|
peer = request.client.host if request.client else None
|
||||||
|
if peer not in {"127.0.0.1", "::1"}:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_bearer_token(request: Request) -> None:
|
||||||
|
"""Verify ``Authorization: Bearer <admin_token>``.
|
||||||
|
|
||||||
|
Uses constant-time comparison so timing leaks don't reveal token
|
||||||
|
prefixes. The 401 deliberately doesn't echo the supplied token or
|
||||||
|
leak whether a token is configured at all — clients should treat
|
||||||
|
"no token configured" the same as "wrong token".
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
expected: Optional[str] = settings.resolve_admin_token()
|
||||||
|
if not expected:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Server not configured for internal access.",
|
||||||
|
)
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if not auth.startswith("Bearer "):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Bearer token required.",
|
||||||
|
)
|
||||||
|
presented = auth.removeprefix("Bearer ").strip()
|
||||||
|
if not hmac.compare_digest(presented, expected):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid token.",
|
||||||
|
)
|
||||||
64
server/app/config.py
Normal file
64
server/app/config.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Runtime configuration loaded from environment + secret files.
|
||||||
|
|
||||||
|
Secrets are read from files (``*_FILE`` env vars pointing at
|
||||||
|
``/run/secrets/<name>``) so they never appear in ``docker inspect``
|
||||||
|
or process environment dumps. Plain ``*`` vars are the fallback for
|
||||||
|
local development where mounting secret files is overkill.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
database_url: str = Field(
|
||||||
|
default="postgresql+psycopg://datatools_api@localhost:5432/datatools_licenses",
|
||||||
|
validation_alias="DATABASE_URL",
|
||||||
|
)
|
||||||
|
|
||||||
|
admin_token: Optional[str] = Field(default=None, validation_alias="DATATOOLS_ADMIN_TOKEN")
|
||||||
|
admin_token_file: Optional[Path] = Field(default=None, validation_alias="DATATOOLS_ADMIN_TOKEN_FILE")
|
||||||
|
|
||||||
|
license_privkey_hex: Optional[str] = Field(default=None, validation_alias="DATATOOLS_LICENSE_PRIVKEY")
|
||||||
|
license_privkey_file: Optional[Path] = Field(default=None, validation_alias="DATATOOLS_LICENSE_PRIVKEY_FILE")
|
||||||
|
|
||||||
|
license_pubkey_hex: Optional[str] = Field(default=None, validation_alias="DATATOOLS_LICENSE_PUBKEY")
|
||||||
|
|
||||||
|
postmark_token: Optional[str] = Field(default=None, validation_alias="POSTMARK_TOKEN")
|
||||||
|
postmark_token_file: Optional[Path] = Field(default=None, validation_alias="POSTMARK_TOKEN_FILE")
|
||||||
|
|
||||||
|
gumroad_secret: Optional[str] = Field(default=None, validation_alias="GUMROAD_WEBHOOK_SECRET")
|
||||||
|
gumroad_secret_file: Optional[Path] = Field(default=None, validation_alias="GUMROAD_WEBHOOK_SECRET_FILE")
|
||||||
|
|
||||||
|
def resolve_admin_token(self) -> Optional[str]:
|
||||||
|
return _resolve(self.admin_token, self.admin_token_file)
|
||||||
|
|
||||||
|
def resolve_license_privkey(self) -> Optional[str]:
|
||||||
|
return _resolve(self.license_privkey_hex, self.license_privkey_file)
|
||||||
|
|
||||||
|
def resolve_postmark_token(self) -> Optional[str]:
|
||||||
|
return _resolve(self.postmark_token, self.postmark_token_file)
|
||||||
|
|
||||||
|
def resolve_gumroad_secret(self) -> Optional[str]:
|
||||||
|
return _resolve(self.gumroad_secret, self.gumroad_secret_file)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(inline: Optional[str], path: Optional[Path]) -> Optional[str]:
|
||||||
|
if inline:
|
||||||
|
return inline.strip()
|
||||||
|
if path and path.exists():
|
||||||
|
return path.read_text(encoding="utf-8").strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
65
server/app/db.py
Normal file
65
server/app/db.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""SQLAlchemy engine + session factory.
|
||||||
|
|
||||||
|
The DB password lives in ``/run/secrets/pg_password``; we read it
|
||||||
|
from there (or ``$PG_PASSWORD`` for local dev) and splice it into
|
||||||
|
``DATABASE_URL`` so the password never has to be in plaintext in
|
||||||
|
``compose.yml`` or process environment listings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Generator
|
||||||
|
from urllib.parse import quote_plus, urlparse, urlunparse
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_password() -> str | None:
|
||||||
|
inline = os.environ.get("PG_PASSWORD")
|
||||||
|
if inline:
|
||||||
|
return inline.strip()
|
||||||
|
path = os.environ.get("PG_PASSWORD_FILE")
|
||||||
|
if path and Path(path).exists():
|
||||||
|
return Path(path).read_text(encoding="utf-8").strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_url(base_url: str) -> str:
|
||||||
|
"""Inject the resolved password into ``base_url`` if absent."""
|
||||||
|
parsed = urlparse(base_url)
|
||||||
|
if parsed.password:
|
||||||
|
return base_url
|
||||||
|
pw = _resolve_password()
|
||||||
|
if pw is None:
|
||||||
|
return base_url
|
||||||
|
netloc = f"{parsed.username or ''}:{quote_plus(pw)}@{parsed.hostname}"
|
||||||
|
if parsed.port:
|
||||||
|
netloc += f":{parsed.port}"
|
||||||
|
return urlunparse(parsed._replace(netloc=netloc))
|
||||||
|
|
||||||
|
|
||||||
|
_settings = get_settings()
|
||||||
|
engine = create_engine(_build_url(_settings.database_url), pool_pre_ping=True, future=True)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""Declarative base for ORM models."""
|
||||||
|
|
||||||
|
|
||||||
|
def get_session() -> Generator[Session, None, None]:
|
||||||
|
"""FastAPI dependency. Commits on success, rolls back on exception."""
|
||||||
|
session = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
session.commit()
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
18
server/app/main.py
Normal file
18
server/app/main.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""FastAPI entry point for the DataTools license server."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app.routes import internal, public
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="DataTools License Server",
|
||||||
|
version="0.1.0",
|
||||||
|
docs_url=None,
|
||||||
|
redoc_url=None,
|
||||||
|
openapi_url=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(public.router)
|
||||||
|
app.include_router(internal.router)
|
||||||
136
server/app/mint.py
Normal file
136
server/app/mint.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""Core mint + revoke logic.
|
||||||
|
|
||||||
|
Bridges the source-adapter layer (:mod:`app.adapters`) to the DB
|
||||||
|
layer (:mod:`app.models`), reusing the desktop app's signing /
|
||||||
|
encoding primitives from ``datatools_license.crypto`` so blobs minted
|
||||||
|
here verify against the same embedded pubkey on the buyer's machine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.adapters.base import SaleEvent
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models import License
|
||||||
|
|
||||||
|
|
||||||
|
def _init_key_env() -> None:
|
||||||
|
"""Resolve secret-file pointers into env vars before importing crypto.
|
||||||
|
|
||||||
|
``datatools_license.crypto`` looks for ``DATATOOLS_LICENSE_PRIVKEY``
|
||||||
|
/ ``DATATOOLS_LICENSE_PUBKEY`` in ``os.environ``. When those come
|
||||||
|
from secret files (``*_FILE`` env vars), we read them once at
|
||||||
|
module import and stash so crypto can pick them up without
|
||||||
|
changes.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
priv = settings.resolve_license_privkey()
|
||||||
|
if priv:
|
||||||
|
os.environ.setdefault("DATATOOLS_LICENSE_PRIVKEY", priv)
|
||||||
|
pub = settings.license_pubkey_hex
|
||||||
|
if pub:
|
||||||
|
os.environ.setdefault("DATATOOLS_LICENSE_PUBKEY", pub)
|
||||||
|
|
||||||
|
|
||||||
|
_init_key_env()
|
||||||
|
|
||||||
|
# Imported after env init so the crypto module reads the correct key.
|
||||||
|
from datatools_license.crypto import encode_blob, sign # noqa: E402
|
||||||
|
from datatools_license.features import all_features_for_tier # noqa: E402
|
||||||
|
from datatools_license.schema import ( # noqa: E402
|
||||||
|
License as LicenseDataclass,
|
||||||
|
Tier,
|
||||||
|
_utcnow_iso,
|
||||||
|
default_expiry_iso,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_license_key(tier: str) -> str:
|
||||||
|
rid = uuid.uuid4().hex
|
||||||
|
return f"DT1-{tier.upper()}-{rid[:8]}-{rid[8:16]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_to_dt(iso: str) -> datetime:
|
||||||
|
return datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
|
||||||
|
def mint_from_sale(session: Session, sale: SaleEvent) -> License:
|
||||||
|
"""Idempotently mint a license for *sale*.
|
||||||
|
|
||||||
|
If a row with the same ``(source, source_order_id)`` already
|
||||||
|
exists, return it untouched — Gumroad retrying a webhook does not
|
||||||
|
produce a second blob with a different signature. Manual mints
|
||||||
|
(``source_order_id is None``) skip the dedup check and always
|
||||||
|
produce a new row.
|
||||||
|
"""
|
||||||
|
if sale.source_order_id is not None:
|
||||||
|
existing = session.execute(
|
||||||
|
select(License).where(
|
||||||
|
License.source == sale.source,
|
||||||
|
License.source_order_id == sale.source_order_id,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
tier_enum = Tier(sale.tier)
|
||||||
|
license_key = _generate_license_key(sale.tier)
|
||||||
|
issued_iso = _utcnow_iso()
|
||||||
|
expires_iso = default_expiry_iso(years=sale.years)
|
||||||
|
|
||||||
|
unsigned = LicenseDataclass(
|
||||||
|
name=sale.buyer_name,
|
||||||
|
email=sale.buyer_email,
|
||||||
|
license_key=license_key,
|
||||||
|
tier=tier_enum,
|
||||||
|
features=all_features_for_tier(tier_enum),
|
||||||
|
issued_at=issued_iso,
|
||||||
|
expires_at=expires_iso,
|
||||||
|
signature="",
|
||||||
|
)
|
||||||
|
signature = sign(unsigned.to_canonical_dict())
|
||||||
|
payload = unsigned.to_canonical_dict()
|
||||||
|
payload["signature"] = signature
|
||||||
|
blob = encode_blob(payload)
|
||||||
|
|
||||||
|
row = License(
|
||||||
|
license_key=license_key,
|
||||||
|
name=sale.buyer_name,
|
||||||
|
email=sale.buyer_email,
|
||||||
|
tier=sale.tier,
|
||||||
|
issued_at=_iso_to_dt(issued_iso),
|
||||||
|
expires_at=_iso_to_dt(expires_iso),
|
||||||
|
blob=blob,
|
||||||
|
source=sale.source,
|
||||||
|
source_order_id=sale.source_order_id,
|
||||||
|
promotion=sale.promotion,
|
||||||
|
amount_paid=sale.amount_paid,
|
||||||
|
currency=sale.currency,
|
||||||
|
notes=sale.notes,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_license(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
license_key: str,
|
||||||
|
reason: Optional[str] = None,
|
||||||
|
) -> Optional[License]:
|
||||||
|
row = session.get(License, license_key)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
row.revoked_at = datetime.now(timezone.utc)
|
||||||
|
if reason:
|
||||||
|
suffix = f"\nRevoked: {reason}"
|
||||||
|
row.notes = ((row.notes or "") + suffix).strip()
|
||||||
|
return row
|
||||||
91
server/app/models.py
Normal file
91
server/app/models.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""ORM models for the licenses + gumroad_events tables.
|
||||||
|
|
||||||
|
Schema mirrors ``docs/LICENSE-SERVER.md``, generalized so any
|
||||||
|
``source`` can populate it. The ``(source, source_order_id)``
|
||||||
|
composite uniqueness key gives idempotent webhook retries — a
|
||||||
|
storefront firing the same sale twice maps to the same row.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
BigInteger,
|
||||||
|
DateTime,
|
||||||
|
Index,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
# JSONB on Postgres (indexable, queryable), plain JSON elsewhere
|
||||||
|
# (SQLite for tests). Same Python interface either way.
|
||||||
|
_JSON_TYPE = JSON().with_variant(JSONB(), "postgresql")
|
||||||
|
|
||||||
|
from app.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class License(Base):
|
||||||
|
__tablename__ = "licenses"
|
||||||
|
|
||||||
|
license_key: Mapped[str] = mapped_column(String, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
email: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
tier: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
issued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
blob: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
|
||||||
|
source: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
source_order_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
promotion: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
amount_paid: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True)
|
||||||
|
currency: Mapped[Optional[str]] = mapped_column(String(3), nullable=True, server_default=text("'USD'"))
|
||||||
|
|
||||||
|
revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
notes: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("source", "source_order_id", name="uq_licenses_source_order"),
|
||||||
|
Index("ix_licenses_email_lower", func.lower(text("email"))),
|
||||||
|
Index("ix_licenses_expires_active", "expires_at", postgresql_where=text("revoked_at IS NULL")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GumroadEvent(Base):
|
||||||
|
"""Append-only audit log of every webhook delivery.
|
||||||
|
|
||||||
|
Stored regardless of processing outcome so we can replay failed
|
||||||
|
events, investigate disputes, and reconstruct the customer
|
||||||
|
record if the ``licenses`` table is ever corrupted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "gumroad_events"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
event_type: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
order_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
raw_payload: Mapped[dict] = mapped_column(_JSON_TYPE, nullable=False)
|
||||||
|
processed: Mapped[bool] = mapped_column(server_default=text("false"), nullable=False)
|
||||||
|
error: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_gumroad_events_order_id", "order_id"),
|
||||||
|
Index("ix_gumroad_events_unprocessed", "received_at", postgresql_where=text("processed = false")),
|
||||||
|
)
|
||||||
0
server/app/routes/__init__.py
Normal file
0
server/app/routes/__init__.py
Normal file
103
server/app/routes/internal.py
Normal file
103
server/app/routes/internal.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Internal (operator-only) routes.
|
||||||
|
|
||||||
|
Two defense layers protect this path:
|
||||||
|
|
||||||
|
1. **nginx** blocks ``/internal/*`` at the public server-block level
|
||||||
|
(``location /internal/ { return 404; }`` in
|
||||||
|
``docs/SETUP-LICENSE-SERVER.md``).
|
||||||
|
2. **Bearer token** authenticates the operator's CLI.
|
||||||
|
|
||||||
|
An earlier draft also enforced a peer-IP loopback check here, but
|
||||||
|
that fights the Docker bridge network model: the container always
|
||||||
|
sees the gateway IP (172.x.0.1) regardless of whether traffic
|
||||||
|
originated from nginx on the host or from outside. The check is
|
||||||
|
preserved as :func:`app.auth.require_localhost` for future use
|
||||||
|
(e.g. if the API ever runs in ``network_mode: host``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.adapters.manual import ManualAdapter
|
||||||
|
from app.auth import require_bearer_token
|
||||||
|
from app.db import get_session
|
||||||
|
from app.mint import mint_from_sale, revoke_license
|
||||||
|
from app.models import License
|
||||||
|
from app.schemas import LicenseResponse, MintRequest, RevokeRequest
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/internal",
|
||||||
|
dependencies=[Depends(require_bearer_token)],
|
||||||
|
)
|
||||||
|
|
||||||
|
_MANUAL = ManualAdapter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mint", response_model=LicenseResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def mint(req: MintRequest, session: Session = Depends(get_session)) -> License:
|
||||||
|
"""Mint a license blob and persist the row.
|
||||||
|
|
||||||
|
PR 1 only wires the ``manual`` source through this endpoint. Real
|
||||||
|
storefront sales (Gumroad et al.) arrive via per-source webhook
|
||||||
|
handlers in PR 2 and bypass this route entirely.
|
||||||
|
"""
|
||||||
|
if req.source != "manual":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
f"Source {req.source!r} is not wired for direct mints. "
|
||||||
|
"Storefront sales arrive via /webhooks/* (PR 2)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sale = _MANUAL.build_sale(
|
||||||
|
name=req.name,
|
||||||
|
email=req.email,
|
||||||
|
tier=req.tier.value,
|
||||||
|
years=req.years,
|
||||||
|
promotion=req.promotion,
|
||||||
|
amount_paid=req.amount_paid,
|
||||||
|
currency=req.currency,
|
||||||
|
notes=req.notes,
|
||||||
|
)
|
||||||
|
return mint_from_sale(session, sale)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/revoke", response_model=LicenseResponse)
|
||||||
|
def revoke(req: RevokeRequest, session: Session = Depends(get_session)) -> License:
|
||||||
|
row = revoke_license(session, license_key=req.license_key, reason=req.reason)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="License not found")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/licenses", response_model=list[LicenseResponse])
|
||||||
|
def list_licenses(
|
||||||
|
email: Optional[str] = Query(default=None, description="Case-insensitive email substring."),
|
||||||
|
tier: Optional[str] = Query(default=None),
|
||||||
|
source: Optional[str] = Query(default=None),
|
||||||
|
include_revoked: bool = Query(default=False),
|
||||||
|
limit: int = Query(default=50, ge=1, le=500),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> list[License]:
|
||||||
|
stmt = select(License).order_by(License.created_at.desc()).limit(limit).offset(offset)
|
||||||
|
if email:
|
||||||
|
stmt = stmt.where(func.lower(License.email).contains(email.lower()))
|
||||||
|
if tier:
|
||||||
|
stmt = stmt.where(License.tier == tier)
|
||||||
|
if source:
|
||||||
|
stmt = stmt.where(License.source == source)
|
||||||
|
if not include_revoked:
|
||||||
|
stmt = stmt.where(License.revoked_at.is_(None))
|
||||||
|
return list(session.execute(stmt).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ping")
|
||||||
|
def ping() -> dict:
|
||||||
|
"""Sanity-check both guards from inside an SSH tunnel."""
|
||||||
|
return {"ok": True}
|
||||||
27
server/app/routes/public.py
Normal file
27
server/app/routes/public.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Public (internet-facing) routes.
|
||||||
|
|
||||||
|
For PR 1: only ``/health``. The webhook receiver and renewal portal
|
||||||
|
land in PR 2 and PR 3 respectively.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import get_session
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def health(session: Session = Depends(get_session)) -> dict:
|
||||||
|
"""Liveness + DB reachability. Cheap; safe to hit on a tight cadence."""
|
||||||
|
db_ok = True
|
||||||
|
try:
|
||||||
|
session.execute(text("SELECT 1"))
|
||||||
|
except SQLAlchemyError:
|
||||||
|
db_ok = False
|
||||||
|
return {"status": "ok" if db_ok else "degraded", "db": "ok" if db_ok else "error"}
|
||||||
54
server/app/schemas.py
Normal file
54
server/app/schemas.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Pydantic request/response models for the HTTP layer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class TierName(str, Enum):
|
||||||
|
lite = "lite"
|
||||||
|
core = "core"
|
||||||
|
pro = "pro"
|
||||||
|
enterprise = "enterprise"
|
||||||
|
|
||||||
|
|
||||||
|
class MintRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=200)
|
||||||
|
email: EmailStr
|
||||||
|
tier: TierName
|
||||||
|
years: int = Field(default=1, ge=1, le=10)
|
||||||
|
source: str = Field(default="manual", min_length=1, max_length=40)
|
||||||
|
source_order_id: Optional[str] = Field(default=None, max_length=120)
|
||||||
|
promotion: Optional[str] = Field(default=None, max_length=60)
|
||||||
|
amount_paid: Optional[Decimal] = Field(default=None, ge=0, decimal_places=2)
|
||||||
|
currency: Optional[str] = Field(default="USD", min_length=3, max_length=3)
|
||||||
|
notes: Optional[str] = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class RevokeRequest(BaseModel):
|
||||||
|
license_key: str = Field(min_length=1, max_length=120)
|
||||||
|
reason: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
license_key: str
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
tier: str
|
||||||
|
issued_at: datetime
|
||||||
|
expires_at: datetime
|
||||||
|
blob: str
|
||||||
|
source: str
|
||||||
|
source_order_id: Optional[str]
|
||||||
|
promotion: Optional[str]
|
||||||
|
amount_paid: Optional[Decimal]
|
||||||
|
currency: Optional[str]
|
||||||
|
revoked_at: Optional[datetime]
|
||||||
|
notes: Optional[str]
|
||||||
41
server/compose.test.yml
Normal file
41
server/compose.test.yml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Smoke-test compose. Stands the API + Postgres up in isolation,
|
||||||
|
# exercises a mint, tears everything down (volume included). Never
|
||||||
|
# meant for production — for that see docs/SETUP-LICENSE-SERVER.md.
|
||||||
|
#
|
||||||
|
# Ports map to 127.0.0.1 only so it can run on a host that already
|
||||||
|
# binds 5432 / 8090 to something else.
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: dt_test
|
||||||
|
POSTGRES_USER: dt_test
|
||||||
|
POSTGRES_PASSWORD: test_pw
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:15432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U dt_test -d dt_test"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: server/Dockerfile
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+psycopg://dt_test:test_pw@postgres:5432/dt_test
|
||||||
|
DATATOOLS_ADMIN_TOKEN: test-admin-token
|
||||||
|
# No DATATOOLS_LICENSE_PRIVKEY — falls back to the in-tree
|
||||||
|
# dev keypair, matching what the desktop dev build expects.
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:18090:8000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--fail", "--silent", "http://localhost:8000/health"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
3
server/requirements-dev.txt
Normal file
3
server/requirements-dev.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.3,<9
|
||||||
|
pytest-asyncio>=0.24,<1
|
||||||
10
server/requirements.txt
Normal file
10
server/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
fastapi>=0.115,<0.120
|
||||||
|
uvicorn[standard]>=0.32,<0.40
|
||||||
|
sqlalchemy>=2.0,<3
|
||||||
|
psycopg[binary]>=3.2,<4
|
||||||
|
alembic>=1.14,<2
|
||||||
|
pydantic>=2.9,<3
|
||||||
|
pydantic-settings>=2.6,<3
|
||||||
|
email-validator>=2.2,<3
|
||||||
|
cryptography>=43,<46
|
||||||
|
httpx>=0.27,<1
|
||||||
84
server/scripts/smoke.sh
Executable file
84
server/scripts/smoke.sh
Executable file
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# End-to-end smoke test for the license server.
|
||||||
|
#
|
||||||
|
# Builds the API image, brings up Postgres + API, runs the Alembic
|
||||||
|
# migration, mints a license through /internal/mint, verifies the
|
||||||
|
# resulting blob's Ed25519 signature against the dev pubkey, and
|
||||||
|
# confirms the row landed in the DB. Tears everything down at exit.
|
||||||
|
#
|
||||||
|
# Run from the server/ directory: ./scripts/smoke.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
PROJECT=dt-license-smoke
|
||||||
|
COMPOSE=(docker compose -p "$PROJECT" -f compose.test.yml)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
echo "--- Tearing down ---"
|
||||||
|
"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "--- Building image ---"
|
||||||
|
"${COMPOSE[@]}" build
|
||||||
|
|
||||||
|
echo "--- Starting stack ---"
|
||||||
|
"${COMPOSE[@]}" up -d
|
||||||
|
|
||||||
|
echo "--- Waiting for API health (max 60s) ---"
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl -sf http://127.0.0.1:18090/health 2>/dev/null | grep -q '"status":"ok"'; then
|
||||||
|
echo "API up after ${i}s"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "--- Running migrations ---"
|
||||||
|
"${COMPOSE[@]}" exec -T api alembic upgrade head
|
||||||
|
|
||||||
|
echo "--- Re-checking health post-migration ---"
|
||||||
|
curl -sf http://127.0.0.1:18090/health | tee /dev/stderr | grep -q '"db":"ok"'
|
||||||
|
|
||||||
|
echo "--- POST /internal/mint ---"
|
||||||
|
RESP=$(curl -s -w "\nHTTP=%{http_code}" -X POST http://127.0.0.1:18090/internal/mint \
|
||||||
|
-H "Authorization: Bearer test-admin-token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Smoke Test","email":"smoke@example.com","tier":"core","source":"manual"}')
|
||||||
|
echo "$RESP"
|
||||||
|
HTTP_CODE=$(echo "$RESP" | tail -n1 | sed 's/HTTP=//')
|
||||||
|
RESP=$(echo "$RESP" | sed '$d')
|
||||||
|
if [ "$HTTP_CODE" != "201" ]; then
|
||||||
|
echo "MINT FAILED (HTTP $HTTP_CODE)"
|
||||||
|
"${COMPOSE[@]}" logs --tail 50 api
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "$RESP" | python3 -m json.tool | head -8
|
||||||
|
|
||||||
|
BLOB=$(echo "$RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin)["blob"])')
|
||||||
|
|
||||||
|
echo "--- Verifying blob signature against host dev pubkey ---"
|
||||||
|
python3 - <<EOF
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "..")
|
||||||
|
from src.license.crypto import decode_blob, verify
|
||||||
|
payload = decode_blob("$BLOB")
|
||||||
|
sig = payload.pop("signature")
|
||||||
|
assert verify(payload, sig), "signature must verify"
|
||||||
|
assert payload["name"] == "Smoke Test"
|
||||||
|
assert payload["email"] == "smoke@example.com"
|
||||||
|
assert payload["tier"] == "core"
|
||||||
|
print("OK: signature verifies, payload matches")
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "--- Verifying DB row ---"
|
||||||
|
"${COMPOSE[@]}" exec -T postgres \
|
||||||
|
psql -U dt_test -d dt_test -t -c \
|
||||||
|
"SELECT license_key, email, tier, source FROM licenses;" \
|
||||||
|
| grep -q smoke@example.com
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===================================="
|
||||||
|
echo " SMOKE TEST PASSED"
|
||||||
|
echo "===================================="
|
||||||
0
server/tests/__init__.py
Normal file
0
server/tests/__init__.py
Normal file
108
server/tests/conftest.py
Normal file
108
server/tests/conftest.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Shared pytest fixtures.
|
||||||
|
|
||||||
|
Tests run against in-memory SQLite — no docker, no Postgres install.
|
||||||
|
The cross-dialect type variants in :mod:`app.models` keep the schema
|
||||||
|
identical in behavior for everything PR 1 exercises (the JSONB
|
||||||
|
column on ``gumroad_events`` isn't touched until PR 2).
|
||||||
|
|
||||||
|
Auth: a fixed test token is wired into the settings cache before any
|
||||||
|
app modules import, and the ``client`` fixture overrides the
|
||||||
|
``require_localhost`` guard since Starlette's TestClient connects
|
||||||
|
from a synthetic ``testclient`` peer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Set required env BEFORE importing anything from app.* so pydantic
|
||||||
|
# Settings (lru_cache'd) picks up these values on first access.
|
||||||
|
os.environ["DATATOOLS_ADMIN_TOKEN"] = "test-admin-token"
|
||||||
|
os.environ["DATABASE_URL"] = "sqlite+pysqlite:///:memory:"
|
||||||
|
|
||||||
|
# Make the desktop license module importable as `datatools_license`.
|
||||||
|
# In the Docker image this happens via `COPY src/license /app/datatools_license`;
|
||||||
|
# during local tests we simulate it by aliasing src.license.
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(_REPO_ROOT))
|
||||||
|
import src.license as _dt_license_module
|
||||||
|
sys.modules.setdefault("datatools_license", _dt_license_module)
|
||||||
|
for _sub in ("crypto", "schema", "features", "_dev_keypair"):
|
||||||
|
sys.modules.setdefault(
|
||||||
|
f"datatools_license.{_sub}",
|
||||||
|
__import__(f"src.license.{_sub}", fromlist=[_sub]),
|
||||||
|
)
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
import app.db as app_db
|
||||||
|
from app.db import Base
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def engine():
|
||||||
|
eng = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enforce foreign keys on SQLite (off by default).
|
||||||
|
@event.listens_for(eng, "connect")
|
||||||
|
def _fk_on(dbapi_conn, _):
|
||||||
|
dbapi_conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
|
||||||
|
Base.metadata.create_all(eng)
|
||||||
|
yield eng
|
||||||
|
eng.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _bind_app_engine(engine, monkeypatch):
|
||||||
|
"""Point the app's session factory at the test engine and wipe rows
|
||||||
|
between tests so order-of-execution can't leak state."""
|
||||||
|
TestSession = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||||
|
monkeypatch.setattr(app_db, "engine", engine)
|
||||||
|
monkeypatch.setattr(app_db, "SessionLocal", TestSession)
|
||||||
|
yield
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for tbl in reversed(Base.metadata.sorted_tables):
|
||||||
|
conn.execute(tbl.delete())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_session(engine):
|
||||||
|
"""Per-test session with rollback isolation."""
|
||||||
|
Session = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||||
|
sess = Session()
|
||||||
|
try:
|
||||||
|
yield sess
|
||||||
|
finally:
|
||||||
|
# Clean rows between tests rather than transaction-rolling (the
|
||||||
|
# mint code flushes mid-transaction and we want each test to
|
||||||
|
# see a clean licenses table).
|
||||||
|
sess.rollback()
|
||||||
|
for tbl in reversed(Base.metadata.sorted_tables):
|
||||||
|
sess.execute(tbl.delete())
|
||||||
|
sess.commit()
|
||||||
|
sess.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
"""Plain TestClient. Bearer-token check is live."""
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_headers() -> dict[str, str]:
|
||||||
|
return {"Authorization": "Bearer test-admin-token"}
|
||||||
52
server/tests/test_adapters.py
Normal file
52
server/tests/test_adapters.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""ManualAdapter — building a SaleEvent from CLI-style kwargs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.adapters.manual import ManualAdapter
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sale_minimal_defaults():
|
||||||
|
a = ManualAdapter()
|
||||||
|
sale = a.build_sale(name="Jane Doe", email="jane@example.com", tier="core")
|
||||||
|
assert sale.source == "manual"
|
||||||
|
assert sale.source_order_id is None
|
||||||
|
assert sale.buyer_name == "Jane Doe"
|
||||||
|
assert sale.buyer_email == "jane@example.com"
|
||||||
|
assert sale.tier == "core"
|
||||||
|
assert sale.years == 1
|
||||||
|
assert sale.currency == "USD"
|
||||||
|
assert sale.promotion is None
|
||||||
|
assert sale.amount_paid is None
|
||||||
|
assert sale.notes is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sale_full_metadata():
|
||||||
|
a = ManualAdapter()
|
||||||
|
sale = a.build_sale(
|
||||||
|
name="Acme",
|
||||||
|
email="ops@acme.example",
|
||||||
|
tier="pro",
|
||||||
|
years=2,
|
||||||
|
promotion="LAUNCH50",
|
||||||
|
amount_paid=Decimal("249.00"),
|
||||||
|
currency="EUR",
|
||||||
|
notes="comp for beta tester",
|
||||||
|
)
|
||||||
|
assert sale.years == 2
|
||||||
|
assert sale.promotion == "LAUNCH50"
|
||||||
|
assert sale.amount_paid == Decimal("249.00")
|
||||||
|
assert sale.currency == "EUR"
|
||||||
|
assert sale.notes == "comp for beta tester"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_webhook_always_false():
|
||||||
|
"""Manual flow never originates from a webhook."""
|
||||||
|
a = ManualAdapter()
|
||||||
|
assert a.verify_webhook(body=b"{}", headers={}) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_refund_returns_none():
|
||||||
|
a = ManualAdapter()
|
||||||
|
assert a.parse_refund({"any": "payload"}) is None
|
||||||
102
server/tests/test_mint.py
Normal file
102
server/tests/test_mint.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""Mint core — signing, persistence, idempotency, revoke."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.base import SaleEvent
|
||||||
|
from app.mint import mint_from_sale, revoke_license
|
||||||
|
from app.models import License
|
||||||
|
from datatools_license.crypto import decode_blob, verify
|
||||||
|
|
||||||
|
|
||||||
|
def _sale(**overrides) -> SaleEvent:
|
||||||
|
base = dict(
|
||||||
|
source="manual",
|
||||||
|
source_order_id=None,
|
||||||
|
buyer_name="Jane Doe",
|
||||||
|
buyer_email="jane@example.com",
|
||||||
|
tier="core",
|
||||||
|
years=1,
|
||||||
|
promotion=None,
|
||||||
|
amount_paid=None,
|
||||||
|
currency="USD",
|
||||||
|
notes=None,
|
||||||
|
)
|
||||||
|
base.update(overrides)
|
||||||
|
return SaleEvent(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mint_persists_and_signs_verifiably(db_session):
|
||||||
|
row = mint_from_sale(db_session, _sale())
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert row.license_key.startswith("DT1-CORE-")
|
||||||
|
assert row.tier == "core"
|
||||||
|
assert row.source == "manual"
|
||||||
|
assert row.blob.startswith("DTLIC2:")
|
||||||
|
assert row.revoked_at is None
|
||||||
|
|
||||||
|
payload = decode_blob(row.blob)
|
||||||
|
sig = payload.pop("signature")
|
||||||
|
assert verify(payload, sig), "minted blob must verify against the dev pubkey"
|
||||||
|
assert payload["name"] == "Jane Doe"
|
||||||
|
assert payload["email"] == "jane@example.com"
|
||||||
|
assert payload["tier"] == "core"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mint_idempotent_on_source_order_id(db_session):
|
||||||
|
"""A second mint with the same (source, source_order_id) returns
|
||||||
|
the existing row — webhook retries cannot double-mint."""
|
||||||
|
first = mint_from_sale(
|
||||||
|
db_session,
|
||||||
|
_sale(source="gumroad", source_order_id="GUM-1001"),
|
||||||
|
)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
second = mint_from_sale(
|
||||||
|
db_session,
|
||||||
|
_sale(source="gumroad", source_order_id="GUM-1001", buyer_name="Different Name"),
|
||||||
|
)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert first.license_key == second.license_key
|
||||||
|
assert second.name == "Jane Doe", "existing row is returned unchanged"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_mints_never_dedup(db_session):
|
||||||
|
"""source_order_id=None means each manual mint creates a new row."""
|
||||||
|
a = mint_from_sale(db_session, _sale())
|
||||||
|
db_session.commit()
|
||||||
|
b = mint_from_sale(db_session, _sale())
|
||||||
|
db_session.commit()
|
||||||
|
assert a.license_key != b.license_key
|
||||||
|
|
||||||
|
|
||||||
|
def test_mint_records_commercial_metadata(db_session):
|
||||||
|
row = mint_from_sale(
|
||||||
|
db_session,
|
||||||
|
_sale(promotion="LAUNCH50", amount_paid=Decimal("79.00"), currency="USD"),
|
||||||
|
)
|
||||||
|
db_session.commit()
|
||||||
|
assert row.promotion == "LAUNCH50"
|
||||||
|
assert Decimal(str(row.amount_paid)) == Decimal("79.00")
|
||||||
|
assert row.currency == "USD"
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_marks_row(db_session):
|
||||||
|
row = mint_from_sale(db_session, _sale())
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
revoked = revoke_license(db_session, license_key=row.license_key, reason="refund")
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert revoked is not None
|
||||||
|
assert revoked.revoked_at is not None
|
||||||
|
assert "refund" in (revoked.notes or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_unknown_returns_none(db_session):
|
||||||
|
assert revoke_license(db_session, license_key="DT1-CORE-no-such-key") is None
|
||||||
130
server/tests/test_routes.py
Normal file
130
server/tests/test_routes.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""HTTP route tests — auth, mint, revoke, list, health."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_is_public(client):
|
||||||
|
r = client.get("/health")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_requires_bearer(client):
|
||||||
|
r = client.get("/internal/ping")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_rejects_wrong_bearer(client):
|
||||||
|
r = client.get("/internal/ping", headers={"Authorization": "Bearer nope"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_ping_ok_with_token(client, admin_headers):
|
||||||
|
r = client.get("/internal/ping", headers=admin_headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mint_creates_license(client, admin_headers):
|
||||||
|
r = client.post(
|
||||||
|
"/internal/mint",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"email": "jane@example.com",
|
||||||
|
"tier": "core",
|
||||||
|
"years": 1,
|
||||||
|
"source": "manual",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["tier"] == "core"
|
||||||
|
assert body["source"] == "manual"
|
||||||
|
assert body["blob"].startswith("DTLIC2:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_mint_rejects_non_manual_source(client, admin_headers):
|
||||||
|
r = client.post(
|
||||||
|
"/internal/mint",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
"name": "x", "email": "x@example.com", "tier": "core",
|
||||||
|
"source": "gumroad",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "not wired" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mint_rejects_bad_email(client, admin_headers):
|
||||||
|
r = client.post(
|
||||||
|
"/internal/mint",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"name": "x", "email": "not-an-email", "tier": "core"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_mint_rejects_unknown_tier(client, admin_headers):
|
||||||
|
r = client.post(
|
||||||
|
"/internal/mint",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"name": "x", "email": "x@example.com", "tier": "platinum"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_licenses_filters_email_case_insensitive(client, admin_headers):
|
||||||
|
# Pydantic EmailStr normalizes the domain to lowercase per RFC.
|
||||||
|
for email in ("alice@example.com", "Bob@Example.com", "carol@other.test"):
|
||||||
|
client.post(
|
||||||
|
"/internal/mint",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"name": "User", "email": email, "tier": "core"},
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.get(
|
||||||
|
"/internal/licenses?email=example.com",
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
emails = {row["email"].lower() for row in r.json()}
|
||||||
|
assert "alice@example.com" in emails
|
||||||
|
assert "bob@example.com" in emails
|
||||||
|
assert "carol@other.test" not in emails
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_then_excluded_by_default(client, admin_headers):
|
||||||
|
r = client.post(
|
||||||
|
"/internal/mint",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"name": "x", "email": "x@example.com", "tier": "lite"},
|
||||||
|
)
|
||||||
|
key = r.json()["license_key"]
|
||||||
|
|
||||||
|
r2 = client.post(
|
||||||
|
"/internal/revoke",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"license_key": key, "reason": "refund"},
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json()["revoked_at"] is not None
|
||||||
|
|
||||||
|
listed = client.get("/internal/licenses", headers=admin_headers).json()
|
||||||
|
assert all(row["license_key"] != key for row in listed)
|
||||||
|
|
||||||
|
listed_all = client.get(
|
||||||
|
"/internal/licenses?include_revoked=true",
|
||||||
|
headers=admin_headers,
|
||||||
|
).json()
|
||||||
|
assert any(row["license_key"] == key for row in listed_all)
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_unknown_returns_404(client, admin_headers):
|
||||||
|
r = client.post(
|
||||||
|
"/internal/revoke",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"license_key": "DT1-CORE-doesnot-exist"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
Reference in New Issue
Block a user