diff --git a/alembic/versions/0011_fc3b_credential_schema_alignment.py b/alembic/versions/0011_fc3b_credential_schema_alignment.py new file mode 100644 index 0000000..00a31a5 --- /dev/null +++ b/alembic/versions/0011_fc3b_credential_schema_alignment.py @@ -0,0 +1,41 @@ +"""fc3b: rename credential.kind -> credential_type, drop status, add last_verified + +Revision ID: 0011 +Revises: 0010 +Create Date: 2026-05-20 + +Aligns the credential table with the GallerySubscriber wire-field names +so the existing browser extension can POST to FC unmodified. Greenfield — +no rows exist in production yet, so no data preservation logic is +needed; the rename uses ALTER COLUMN rather than copy-then-drop. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0011" +down_revision: Union[str, None] = "0010" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column("credential", "kind", new_column_name="credential_type") + op.drop_column("credential", "status") + op.add_column( + "credential", + sa.Column("last_verified", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("credential", "last_verified") + op.add_column( + "credential", + sa.Column( + "status", sa.String(length=32), nullable=False, + server_default="active", + ), + ) + op.alter_column("credential", "credential_type", new_column_name="kind") diff --git a/backend/app/models/credential.py b/backend/app/models/credential.py index 667a7c0..2f07732 100644 --- a/backend/app/models/credential.py +++ b/backend/app/models/credential.py @@ -1,5 +1,8 @@ -"""Credential — encrypted blob (cookies/token/oauth) scoped per-platform, +"""Credential — encrypted blob (cookies/token) scoped per-platform, not per-source. One Patreon credential serves every Patreon source. + +Schema aligned with GallerySubscriber's wire format (FC-3b) so the +existing browser extension hits FC unmodified. """ from datetime import datetime @@ -15,10 +18,12 @@ class Credential(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True) platform: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) - kind: Mapped[str] = mapped_column(String(32), nullable=False) # cookies|token|oauth + credential_type: Mapped[str] = mapped_column(String(32), nullable=False) # cookies|token encrypted_blob: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) - status: Mapped[str] = mapped_column(String(32), nullable=False, default="active") captured_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_verified: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) diff --git a/tests/test_migration_0011.py b/tests/test_migration_0011.py new file mode 100644 index 0000000..17e9702 --- /dev/null +++ b/tests/test_migration_0011.py @@ -0,0 +1,32 @@ +import pytest +from sqlalchemy import inspect, text + +pytestmark = pytest.mark.integration + + +@pytest.mark.asyncio +async def test_credential_has_credential_type_not_kind(db): + cols = (await db.run_sync( + lambda sync_session: [c["name"] for c in inspect(sync_session.bind).get_columns("credential")] + )) + assert "credential_type" in cols + assert "kind" not in cols + assert "status" not in cols + assert "last_verified" in cols + + +@pytest.mark.asyncio +async def test_credential_round_trip(db): + from backend.app.models import Credential + + db.add(Credential( + platform="patreon", + credential_type="cookies", + encrypted_blob=b"\x00\x01\x02", + )) + await db.flush() + row = (await db.execute( + text("SELECT credential_type, last_verified FROM credential WHERE platform='patreon'") + )).one() + assert row.credential_type == "cookies" + assert row.last_verified is None