feat(fc3b): migration 0011 — credential schema aligned with GS wire fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||||
@@ -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.
|
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
|
from datetime import datetime
|
||||||
@@ -15,10 +18,12 @@ class Credential(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
platform: Mapped[str] = mapped_column(String(64), nullable=False, unique=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)
|
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(
|
captured_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_verified: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user