diff --git a/e2e/tests/10-passkey.spec.ts b/e2e/tests/10-passkey.spec.ts index ba1cb9b..c1a4287 100644 --- a/e2e/tests/10-passkey.spec.ts +++ b/e2e/tests/10-passkey.spec.ts @@ -138,9 +138,9 @@ test.describe('Passkey Authentication E2E', () => { const validation = await validateSession(page, baseUrl, sessionToken) expect(validation.valid).toBe(true) - expect(validation.user_uuid).toBe(userUuid) + expect(validation.ctx.user.uuid).toBe(userUuid) - console.log(`✓ Session validated for user: ${validation.user_uuid}`) + console.log(`✓ Session validated for user: ${validation.ctx.user.uuid}`) }) test('should retrieve user info', async ({ page }) => { @@ -148,8 +148,8 @@ test.describe('Passkey Authentication E2E', () => { const userInfo = await getUserInfo(page, baseUrl, sessionToken) - expect(userInfo.user.user_uuid).toBe(userUuid) - expect(userInfo.user.user_name).toBe('Admin User') + expect(userInfo.ctx.user.uuid).toBe(userUuid) + expect(userInfo.ctx.user.display_name).toBe('Admin User') expect(userInfo.credentials).toBeDefined() expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1) @@ -169,7 +169,7 @@ test.describe('Passkey Authentication E2E', () => { await page.screenshot({ path: 'test-results/profile-view.png' }) console.log('✓ Screenshot saved: test-results/profile-view.png') - console.log(`✓ User info retrieved: ${userInfo.user.user_name}`) + console.log(`✓ User info retrieved: ${userInfo.ctx.user.display_name}`) console.log(`✓ Credentials count: ${userInfo.credentials.length}`) }) @@ -219,7 +219,7 @@ test.describe('Passkey Authentication E2E', () => { const validation = await validateSession(page, baseUrl, sessionToken) expect(validation.valid).toBe(true) - expect(validation.user_uuid).toBe(userUuid) + expect(validation.ctx.user.uuid).toBe(userUuid) console.log(`✓ New session validated`) }) @@ -291,8 +291,8 @@ test.describe('Device Addition Dialog', () => { // Wait for the profile view to load await page.waitForSelector('[data-view="profile"]', { timeout: 5000 }) - // Click the "Add Another Device" button - const addDeviceButton = page.getByRole('button', { name: 'Add Another Device' }) + // Click the "Another Device" button + const addDeviceButton = page.getByRole('button', { name: 'Another Device' }) await expect(addDeviceButton).toBeVisible() await addDeviceButton.click() @@ -301,7 +301,7 @@ test.describe('Device Addition Dialog', () => { await expect(dialog).toBeVisible({ timeout: 5000 }) // Verify dialog contains expected elements - await expect(dialog.locator('h2')).toContainText('Device Registration Link') + await expect(dialog.locator('h2')).toContainText('Add Another Device') // Wait for QR code to be generated (canvas should have content) const qrCanvas = dialog.locator('.qr-code') @@ -318,16 +318,16 @@ test.describe('Device Addition Dialog', () => { expect(linkHref).toContain('http://localhost:4404/auth/') console.log(`✓ Device link displayed: ${linkText} (href: ${linkHref})`) - // Verify expiration warning is shown - await expect(dialog.locator('.reg-help')).toContainText('Expires') + // Verify help text is shown + await expect(dialog.locator('.reg-help')).toContainText('Scan this QR code') // Take screenshot of the dialog await dialog.screenshot({ path: 'test-results/device-addition-dialog.png' }) console.log(`✓ Screenshot saved: test-results/device-addition-dialog.png`) - // Verify Copy Link button exists - const copyButton = dialog.getByRole('button', { name: 'Copy Link' }) - await expect(copyButton).toBeVisible() + // Verify the QR link element is clickable (copy functionality is built into clicking it) + const qrLink = dialog.locator('a.qr-link') + await expect(qrLink).toBeVisible() // Close the dialog (use the text button, not the icon button) const closeButton = dialog.locator('button.btn-secondary', { hasText: 'Close' }) @@ -357,12 +357,12 @@ test.describe('Device Addition Dialog', () => { await page.waitForSelector('[data-view="profile"]', { timeout: 5000 }) // Open the dialog - await page.getByRole('button', { name: 'Add Another Device' }).click() + await page.getByRole('button', { name: 'Another Device' }).click() const dialog = page.locator('.device-dialog') await expect(dialog).toBeVisible({ timeout: 5000 }) // Extract the reset token from the displayed URL - const linkText = dialog.locator('.qr-link p') + const linkText = dialog.locator('.qr-link .link-text') const linkContent = await linkText.textContent() // URL format: localhost/auth/word1.word2.word3.word4.word5 @@ -405,7 +405,7 @@ test.describe('Device Addition Dialog', () => { }) }) -test.describe('ProfileView - Add New Passkey', () => { +test.describe('ProfileView - Register New', () => { const baseUrl = process.env.BASE_URL || 'http://localhost:4404' test('should show credentials list in profile', async ({ page }) => { @@ -427,7 +427,7 @@ test.describe('ProfileView - Add New Passkey', () => { console.log(`✓ Profile shows ${credentialItems} credential(s) in list`) }) - test('should add a new passkey using Add New Passkey button', async ({ page }) => { + test('should add a new passkey using Register New button', async ({ page }) => { const sessionToken = getSavedSessionToken() test.skip(!sessionToken, 'Requires saved session token') @@ -444,8 +444,8 @@ test.describe('ProfileView - Add New Passkey', () => { const initialCredentialCount = await page.locator('.credential-item').count() console.log(`Initial credential count: ${initialCredentialCount}`) - // Click "Add New Passkey" button - const addPasskeyBtn = page.locator('button:has-text("Add New Passkey")') + // Click "Register New" button + const addPasskeyBtn = page.locator('button:has-text("Register New")') await expect(addPasskeyBtn).toBeVisible() await addPasskeyBtn.click() @@ -490,7 +490,7 @@ test.describe('ProfileView - Add New Passkey', () => { // Try to add a passkey - with excludeCredentials the authenticator should // prevent re-registration of the same credential - const addPasskeyBtn = page.locator('button:has-text("Add New Passkey")') + const addPasskeyBtn = page.locator('button:has-text("Register New")') await expect(addPasskeyBtn).toBeVisible() await addPasskeyBtn.click() @@ -541,8 +541,8 @@ test.describe('ProfileView - Multi-Authenticator', () => { await page.waitForSelector('.credential-list', { timeout: 10000 }) const initialCredentialCount = await page.locator('.credential-item').count() - // Click "Add New Passkey" button - const addPasskeyBtn = page.locator('button:has-text("Add New Passkey")') + // Click "Register New" button + const addPasskeyBtn = page.locator('button:has-text("Register New")') await expect(addPasskeyBtn).toBeVisible() await addPasskeyBtn.click() diff --git a/e2e/tests/20-api-auth.spec.ts b/e2e/tests/20-api-auth.spec.ts index af79770..95e1b43 100644 --- a/e2e/tests/20-api-auth.spec.ts +++ b/e2e/tests/20-api-auth.spec.ts @@ -268,7 +268,7 @@ test.describe('API Mode - 401 Login Flow', () => { // Wait for API call to complete and verify result const result = await apiCallPromise expect(result.status).toBe(200) - expect(result.data.user).toBeDefined() + expect(result.data.ctx).toBeDefined() console.log('✓ API call succeeded after authentication') // Save the session for other tests diff --git a/e2e/tests/fixtures/passkey-helpers.ts b/e2e/tests/fixtures/passkey-helpers.ts index b2e7d6b..3479cc8 100644 --- a/e2e/tests/fixtures/passkey-helpers.ts +++ b/e2e/tests/fixtures/passkey-helpers.ts @@ -23,6 +23,40 @@ export interface AuthenticationResult { session_token: string } +export interface SessionContext { + user: { uuid: string; display_name: string } + org: { uuid: string; display_name: string } + role: { uuid: string; display_name: string } + permissions: string[] +} + +export interface UserInfo { + ctx: SessionContext + created_at: string + last_seen: string + visits: number + credentials: Array<{ + credential_uuid: string + aaguid: string + created_at: string + last_used: string | null + last_verified: string | null + sign_count: number + is_current_session: boolean + }> + aaguid_info: Record + sessions: Array<{ + id: string + credential_uuid: string + host: string + ip: string + user_agent: string + last_renewed: string + is_current: boolean + is_current_host: boolean + }> +} + /** * Get the bootstrap reset token from the test state file. */ @@ -376,7 +410,7 @@ export async function validateSession( page: Page, baseUrl: string, sessionToken: string -): Promise<{ valid: boolean; user_uuid: string; renewed: boolean }> { +): Promise<{ valid: boolean; ctx: SessionContext; renewed: boolean }> { const cookieName = getSessionCookieName() const response = await page.request.post(`${baseUrl}/auth/api/validate`, { headers: { @@ -393,7 +427,7 @@ export async function getUserInfo( page: Page, baseUrl: string, sessionToken: string -): Promise { +): Promise { const cookieName = getSessionCookieName() const response = await page.request.post(`${baseUrl}/auth/api/user-info`, { headers: { diff --git a/e2e/tests/global-setup.ts b/e2e/tests/global-setup.ts index 128a12e..4865ea1 100644 --- a/e2e/tests/global-setup.ts +++ b/e2e/tests/global-setup.ts @@ -42,21 +42,23 @@ export default async function globalSetup() { const serverArgs = COLLECT_COVERAGE ? [ 'run', 'coverage', 'run', '--parallel-mode', - '-m', 'paskia.fastapi', 'serve', 'localhost:4404', + '-m', 'paskia.fastapi', 'localhost:4404', '--rp-id', 'localhost' ] : [ - 'run', 'paskia', 'serve', 'localhost:4404', + 'run', 'paskia', 'localhost:4404', '--rp-id', 'localhost' ] + // Use a temporary jsonl file for test database + const testDbFile = join(testDataDir, 'test-db.jsonl') + // Start the server using Node's spawn - // Use in-memory SQLite for faster tests const serverProcess = spawn('uv', serverArgs, { cwd: projectRoot, env: { ...process.env, - PASKIA_DB: 'sqlite+aiosqlite:///:memory:', + PASKIA_DB: testDbFile, COVERAGE_FILE: join(projectRoot, '.coverage'), }, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/e2e/tests/global-teardown.ts b/e2e/tests/global-teardown.ts index 8ca0abf..3a1c0bc 100644 --- a/e2e/tests/global-teardown.ts +++ b/e2e/tests/global-teardown.ts @@ -59,18 +59,11 @@ export default async function globalTeardown() { rmSync(stateFile, { force: true }) } - // Optionally clean up test database (keep it for debugging by default) - if (process.env.CLEANUP_TEST_DB === 'true') { - const dbPath = join(testDataDir, 'test.sqlite') - if (existsSync(dbPath)) { - console.log(' Removing test database...') - rmSync(dbPath) - } - // Remove wal/shm files too - for (const ext of ['-wal', '-shm']) { - const file = dbPath + ext - if (existsSync(file)) rmSync(file) - } + // Clean up test database + const testDbFile = join(testDataDir, 'test-db.jsonl') + if (existsSync(testDbFile)) { + console.log(' Removing test database...') + rmSync(testDbFile) } // Generate Python coverage report if coverage was collected diff --git a/paskia/db/operations.py b/paskia/db/operations.py index 6a07619..7d9fbdb 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -37,13 +37,9 @@ from paskia.db.structs import ( Session, SessionContext, User, - _CredentialData, _DatabaseData, _OrgData, - _PermissionData, - _ResetTokenData, _RoleData, - _SessionData, ) from paskia.util.passphrase import is_well_formed as _is_passphrase @@ -170,10 +166,9 @@ async def init(*args, **kwargs): def build_permission(uuid: UUID) -> Permission: - p = _db._data.permissions[uuid] - return Permission( - uuid=uuid, scope=p.scope, display_name=p.display_name, domain=p.domain - ) + perm = _db._data.permissions[uuid] + perm.uuid = uuid + return perm def build_user(uuid: UUID) -> User: @@ -184,12 +179,13 @@ def build_user(uuid: UUID) -> User: def build_role(uuid: UUID) -> Role: r = _db._data.roles[uuid] - return Role( - uuid=uuid, - org_uuid=r.org, + role = Role( + org=r.org, display_name=r.display_name, permissions=[str(pid) for pid in r.permissions.keys()], ) + role.uuid = uuid + return role def build_org(uuid: UUID, include_roles: bool = False) -> Org: @@ -197,7 +193,8 @@ def build_org(uuid: UUID, include_roles: bool = False) -> Org: perm_uuids = [ str(pid) for pid, p in _db._data.permissions.items() if uuid in p.orgs ] - org = Org(uuid=uuid, display_name=o.display_name, permissions=perm_uuids) + org = Org(display_name=o.display_name, permissions=perm_uuids) + org.uuid = uuid if include_roles: org.roles = [ build_role(rid) for rid, r in _db._data.roles.items() if r.org == uuid @@ -206,41 +203,21 @@ def build_org(uuid: UUID, include_roles: bool = False) -> Org: def build_credential(uuid: UUID) -> Credential: - c = _db._data.credentials[uuid] - return Credential( - uuid=uuid, - credential_id=c.credential_id, - user_uuid=c.user, - aaguid=c.aaguid, - public_key=c.public_key, - sign_count=c.sign_count, - created_at=c.created_at, - last_used=c.last_used, - last_verified=c.last_verified, - ) + cred = _db._data.credentials[uuid] + cred.uuid = uuid + return cred def build_session(key: str) -> Session: s = _db._data.sessions[key] - return Session( - key=key, - user_uuid=s.user, - credential_uuid=s.credential, - host=s.host, - ip=s.ip, - user_agent=s.user_agent, - expiry=s.expiry, - ) + s.key = key + return s def build_reset_token(key: bytes) -> ResetToken: t = _db._data.reset_tokens[key] - return ResetToken( - key=key, - user_uuid=t.user, - expiry=t.expiry, - token_type=t.token_type, - ) + t.key = key + return t # ------------------------------------------------------------------------- @@ -520,12 +497,7 @@ def create_permission(perm: Permission, *, ctx: SessionContext | None = None) -> if perm.uuid in _db._data.permissions: raise ValueError(f"Permission {perm.uuid} already exists") with _db.transaction("Created permission", ctx): - _db._data.permissions[perm.uuid] = _PermissionData( - scope=perm.scope, - display_name=perm.display_name, - domain=perm.domain, - orgs={}, - ) + _db._data.permissions[perm.uuid] = perm def update_permission(perm: Permission, *, ctx: SessionContext | None = None) -> None: @@ -724,11 +696,11 @@ def create_role(role: Role, *, ctx: SessionContext | None = None) -> None: """Create a new role.""" if role.uuid in _db._data.roles: raise ValueError(f"Role {role.uuid} already exists") - if role.org_uuid not in _db._data.orgs: - raise ValueError(f"Organization {role.org_uuid} not found") + if role.org not in _db._data.orgs: + raise ValueError(f"Organization {role.org} not found") with _db.transaction("Created role", ctx): _db._data.roles[role.uuid] = _RoleData( - org=role.org_uuid, + org=role.org, display_name=role.display_name, permissions={UUID(pid): True for pid in role.permissions}, ) @@ -901,19 +873,10 @@ def create_credential(cred: Credential, *, ctx: SessionContext | None = None) -> """Create a new credential.""" if cred.uuid in _db._data.credentials: raise ValueError(f"Credential {cred.uuid} already exists") - if cred.user_uuid not in _db._data.users: - raise ValueError(f"User {cred.user_uuid} not found") + if cred.user not in _db._data.users: + raise ValueError(f"User {cred.user} not found") with _db.transaction("Added credential", ctx): - _db._data.credentials[cred.uuid] = _CredentialData( - credential_id=cred.credential_id, - user=cred.user_uuid, - aaguid=cred.aaguid, - public_key=cred.public_key, - sign_count=cred.sign_count, - created_at=cred.created_at, - last_used=cred.last_used, - last_verified=cred.last_verified, - ) + _db._data.credentials[cred.uuid] = cred def update_credential_sign_count( @@ -981,7 +944,7 @@ def create_session( if credential_uuid not in _db._data.credentials: raise ValueError(f"Credential {credential_uuid} not found") with _db.transaction("Created session", ctx): - _db._data.sessions[key] = _SessionData( + _db._data.sessions[key] = Session( user=user_uuid, credential=credential_uuid, host=host, @@ -1073,7 +1036,7 @@ def create_reset_token( # For self-service, derive user from the user_uuid param user_str = str(user_uuid) if not ctx else None with _db.transaction("Created reset token", ctx, user=user_str): - _db._data.reset_tokens[key] = _ResetTokenData( + _db._data.reset_tokens[key] = ResetToken( user=user_uuid, expiry=expiry, token_type=token_type ) @@ -1155,7 +1118,7 @@ def login( _db._data.credentials[credential.uuid].sign_count = credential.sign_count _db._data.credentials[credential.uuid].last_used = now # Create session - _db._data.sessions[session_key] = _SessionData( + _db._data.sessions[session_key] = Session( user=user_uuid, credential=credential.uuid, host=host, @@ -1201,19 +1164,10 @@ def create_credential_session( _db._data.users[user_uuid].display_name = display_name # Create credential - _db._data.credentials[credential.uuid] = _CredentialData( - credential_id=credential.credential_id, - user=user_uuid, - aaguid=credential.aaguid, - public_key=credential.public_key, - sign_count=credential.sign_count, - created_at=credential.created_at, - last_used=credential.last_used, - last_verified=credential.last_verified, - ) + _db._data.credentials[credential.uuid] = credential # Create session - _db._data.sessions[session_key] = _SessionData( + _db._data.sessions[session_key] = Session( user=user_uuid, credential=credential.uuid, host=host, @@ -1291,18 +1245,22 @@ def bootstrap( with _db.transaction("bootstrap"): # Create auth:admin permission - _db._data.permissions[perm_admin_uuid] = _PermissionData( + perm_admin = Permission( scope="auth:admin", display_name="Master Admin", orgs={org_uuid: True}, # Grant to org ) + perm_admin.uuid = perm_admin_uuid + _db._data.permissions[perm_admin_uuid] = perm_admin # Create auth:org:admin permission - _db._data.permissions[perm_org_admin_uuid] = _PermissionData( + perm_org_admin = Permission( scope="auth:org:admin", display_name="Org Admin", orgs={org_uuid: True}, # Grant to org ) + perm_org_admin.uuid = perm_org_admin_uuid + _db._data.permissions[perm_org_admin_uuid] = perm_org_admin # Create organization _db._data.orgs[org_uuid] = _OrgData( @@ -1329,7 +1287,7 @@ def bootstrap( _db._data.users[user_uuid] = admin_user # Create reset token - _db._data.reset_tokens[reset_key] = _ResetTokenData( + _db._data.reset_tokens[reset_key] = ResetToken( user=user_uuid, expiry=reset_expiry, token_type="admin bootstrap", diff --git a/paskia/db/structs.py b/paskia/db/structs.py index f956ca8..b30c59d 100644 --- a/paskia/db/structs.py +++ b/paskia/db/structs.py @@ -5,26 +5,84 @@ import msgspec import uuid7 -class Permission(msgspec.Struct, omit_defaults=True): - uuid: UUID # UUID primary key +class Permission(msgspec.Struct, dict=True, omit_defaults=True): scope: str # Permission scope identifier (e.g. "auth:admin", "myapp:write") display_name: str domain: str | None = None # If set, scopes permission to this domain + orgs: dict[UUID, bool] = {} # org_uuid -> True (which orgs can grant this) + + def __post_init__(self): + self.uuid: UUID | None = None # Convenience field, not serialized + + @classmethod + def create( + cls, + scope: str, + display_name: str, + domain: str | None = None, + ) -> "Permission": + """Create a new Permission with auto-generated uuid7.""" + perm = cls( + scope=scope, + display_name=display_name, + domain=domain, + ) + perm.uuid = uuid7.create() + return perm -class Role(msgspec.Struct): - uuid: UUID - org_uuid: UUID +class Role(msgspec.Struct, dict=True): + org: UUID display_name: str permissions: list[str] = [] # permission UUIDs this role grants + def __post_init__(self): + self.uuid: UUID | None = None # Convenience field, not serialized -class Org(msgspec.Struct): - uuid: UUID + @classmethod + def create( + cls, + org: UUID, + display_name: str, + permissions: list[str] | None = None, + ) -> "Role": + """Create a new Role with auto-generated uuid7.""" + role = cls( + org=org, + display_name=display_name, + permissions=permissions or [], + ) + role.uuid = uuid7.create() + return role + + # Legacy alias for org field + @property + def org_uuid(self) -> UUID: + return self.org + + +class Org(msgspec.Struct, dict=True): display_name: str permissions: list[str] = [] # permission UUIDs this org can grant roles: list[Role] = [] # roles belonging to this org + def __post_init__(self): + self.uuid: UUID | None = None # Convenience field, not serialized + + @classmethod + def create( + cls, + display_name: str, + permissions: list[str] | None = None, + ) -> "Org": + """Create a new Org with auto-generated uuid7.""" + org = cls( + display_name=display_name, + permissions=permissions or [], + ) + org.uuid = uuid7.create() + return org + class User(msgspec.Struct, dict=True): display_name: str @@ -55,10 +113,9 @@ class User(msgspec.Struct, dict=True): return user -class Credential(msgspec.Struct): - uuid: UUID +class Credential(msgspec.Struct, dict=True): credential_id: bytes # Long binary ID from the authenticator - user_uuid: UUID + user: UUID aaguid: UUID public_key: bytes sign_count: int @@ -66,16 +123,57 @@ class Credential(msgspec.Struct): last_used: datetime | None = None last_verified: datetime | None = None + def __post_init__(self): + self.uuid: UUID | None = None # Convenience field, not serialized -class Session(msgspec.Struct): - key: str - user_uuid: UUID - credential_uuid: UUID + @classmethod + def create( + cls, + credential_id: bytes, + user: UUID, + aaguid: UUID, + public_key: bytes, + sign_count: int, + created_at: datetime | None = None, + ) -> "Credential": + """Create a new Credential with auto-generated uuid7.""" + from datetime import timezone + + now = created_at or datetime.now(timezone.utc) + cred = cls( + credential_id=credential_id, + user=user, + aaguid=aaguid, + public_key=public_key, + sign_count=sign_count, + created_at=now, + last_used=now, + last_verified=now, + ) + cred.uuid = uuid7.create(now) + return cred + + +class Session(msgspec.Struct, dict=True): + user: UUID + credential: UUID host: str | None ip: str | None user_agent: str | None expiry: datetime + def __post_init__(self): + self.key: str | None = None # Convenience field, not serialized + + # Legacy aliases + @property + def user_uuid(self) -> UUID: + return self.user + + @property + def credential_uuid(self) -> UUID: + return self.credential + def metadata(self) -> dict: """Return session metadata for backwards compatibility.""" return { @@ -85,12 +183,19 @@ class Session(msgspec.Struct): } -class ResetToken(msgspec.Struct): - key: bytes - user_uuid: UUID +class ResetToken(msgspec.Struct, dict=True): + user: UUID expiry: datetime token_type: str + def __post_init__(self): + self.key: bytes | None = None # Convenience field, not serialized + + # Legacy alias + @property + def user_uuid(self) -> UUID: + return self.user + class SessionContext(msgspec.Struct): session: Session @@ -106,13 +211,6 @@ class SessionContext(msgspec.Struct): # ------------------------------------------------------------------------- -class _PermissionData(msgspec.Struct, omit_defaults=True): - scope: str # Permission scope identifier - display_name: str - domain: str | None = None - orgs: dict[UUID, bool] = {} # org_uuid -> True (which orgs can grant this) - - class _OrgData(msgspec.Struct): display_name: str created_at: datetime | None = None @@ -124,38 +222,12 @@ class _RoleData(msgspec.Struct): permissions: dict[UUID, bool] = {} # permission_uuid -> True -class _CredentialData(msgspec.Struct): - credential_id: bytes - user: UUID - aaguid: UUID - public_key: bytes - sign_count: int - created_at: datetime - last_used: datetime | None - last_verified: datetime | None - - -class _SessionData(msgspec.Struct): - user: UUID - credential: UUID - host: str | None - ip: str | None - user_agent: str | None - expiry: datetime - - -class _ResetTokenData(msgspec.Struct): - user: UUID - expiry: datetime - token_type: str - - class _DatabaseData(msgspec.Struct, omit_defaults=True): - permissions: dict[UUID, _PermissionData] + permissions: dict[UUID, Permission] orgs: dict[UUID, _OrgData] roles: dict[UUID, _RoleData] users: dict[UUID, User] - credentials: dict[UUID, _CredentialData] - sessions: dict[str, _SessionData] - reset_tokens: dict[bytes, _ResetTokenData] + credentials: dict[UUID, Credential] + sessions: dict[str, Session] + reset_tokens: dict[bytes, ResetToken] v: int = 0 diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index 198db49..505b1aa 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -135,13 +135,12 @@ async def admin_create_org( ) from ..db import Org as OrgDC # local import to avoid cycles - org_uuid = uuid4() display_name = payload.get("display_name") or "New Organization" permissions = payload.get("permissions") or [] - org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) + org = OrgDC.create(display_name=display_name, permissions=permissions) db.create_organization(org, ctx=ctx) - return {"uuid": str(org_uuid)} + return {"uuid": str(org.uuid)} @app.patch("/orgs/{org_uuid}") @@ -264,7 +263,6 @@ async def admin_create_role( ) from ..db import Role as RoleDC - role_uuid = uuid4() display_name = payload.get("display_name") or "New Role" perms = payload.get("permissions") or [] org = db.get_organization(str(org_uuid)) @@ -281,14 +279,13 @@ async def admin_create_role( raise ValueError(f"Permission not grantable by org: {pid}") permission_uuids.append(perm_uuid_str) - role = RoleDC( - uuid=role_uuid, - org_uuid=org_uuid, + role = RoleDC.create( + org=org_uuid, display_name=display_name, permissions=permission_uuids, ) db.create_role(role, ctx=ctx) - return {"uuid": str(role_uuid)} + return {"uuid": str(role.uuid)} @app.patch("/orgs/{org_uuid}/roles/{role_uuid}") @@ -939,8 +936,6 @@ async def admin_create_permission( match=permutil.has_all, max_age="5m", ) - import uuid7 - from ..db import Permission as PermDC scope = payload.get("scope") or payload.get( @@ -953,9 +948,7 @@ async def admin_create_permission( querysafe.assert_safe(scope, field="scope") _validate_permission_domain(domain) db.create_permission( - PermDC( - uuid=uuid7.create(), scope=scope, display_name=display_name, domain=domain - ), + PermDC.create(scope=scope, display_name=display_name, domain=domain), ctx=ctx, ) return {"status": "ok"} @@ -999,15 +992,13 @@ async def admin_update_permission( from ..db import Permission as PermDC - db.update_permission( - PermDC( - uuid=perm.uuid, - scope=new_scope, - display_name=new_display_name, - domain=domain_value, - ), - ctx=ctx, + updated_perm = PermDC( + scope=new_scope, + display_name=new_display_name, + domain=domain_value, ) + updated_perm.uuid = perm.uuid + db.update_permission(updated_perm, ctx=ctx) return {"status": "ok"} diff --git a/paskia/fastapi/remote.py b/paskia/fastapi/remote.py index e1f733b..c2e6856 100644 --- a/paskia/fastapi/remote.py +++ b/paskia/fastapi/remote.py @@ -347,7 +347,7 @@ async def websocket_remote_auth_permit(ws: WebSocket): token_str = passphrase.generate() expiry = expires() db.create_reset_token( - user_uuid=stored_cred.user_uuid, + user_uuid=stored_cred.user, passphrase=token_str, expiry=expiry, token_type="device addition", @@ -356,7 +356,7 @@ async def websocket_remote_auth_permit(ws: WebSocket): # Also create a session so the device is logged in normalized_host = hostutil.normalize_host(request.host) session_token = db.login( - user_uuid=stored_cred.user_uuid, + user_uuid=stored_cred.user, credential=stored_cred, host=normalized_host, ip=request.ip, @@ -370,7 +370,7 @@ async def websocket_remote_auth_permit(ws: WebSocket): normalized_host = hostutil.normalize_host(request.host) session_token = db.login( - user_uuid=stored_cred.user_uuid, + user_uuid=stored_cred.user, credential=stored_cred, host=normalized_host, ip=request.ip, @@ -382,7 +382,7 @@ async def websocket_remote_auth_permit(ws: WebSocket): completed = await remoteauth.instance.complete_request( token=request.key, session_token=session_token, - user_uuid=stored_cred.user_uuid, + user_uuid=stored_cred.user, credential_uuid=stored_cred.uuid, reset_token=reset_token, ) diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index 44e6cb2..c7decd8 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -136,7 +136,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): ) # If reauth mode, verify the credential belongs to the session's user - if session_user_uuid and stored_cred.user_uuid != session_user_uuid: + if session_user_uuid and stored_cred.user != session_user_uuid: raise ValueError("This passkey belongs to a different account") # Verify the credential matches the stored data @@ -154,7 +154,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): raise ValueError(f"Host must be the same as or a subdomain of {rp_id}") token = db.login( - user_uuid=stored_cred.user_uuid, + user_uuid=stored_cred.user, credential=stored_cred, host=normalized_host, ip=metadata.get("ip") or "", @@ -164,7 +164,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): await ws.send_json( { - "user_uuid": str(stored_cred.user_uuid), + "user_uuid": str(stored_cred.user), "session_token": token, } ) diff --git a/paskia/migrate/__init__.py b/paskia/migrate/__init__.py index 56f1d61..afa7737 100644 --- a/paskia/migrate/__init__.py +++ b/paskia/migrate/__init__.py @@ -55,13 +55,13 @@ async def migrate_from_sql( from paskia.db.operations import DB as JSONDB from paskia.db.structs import ( + Credential, + Permission, + ResetToken, + Session, User, - _CredentialData, _OrgData, - _PermissionData, - _ResetTokenData, _RoleData, - _SessionData, ) # Initialize source SQL database @@ -90,11 +90,13 @@ async def migrate_from_sql( # Migrate permissions with UUID keys and scope field # Always create exactly one common auth:org:admin permission for all org admin needs org_admin_perm_uuid: UUID = uuid7.create() - json_db._data.permissions[org_admin_perm_uuid] = _PermissionData( + org_admin_perm = Permission( scope="auth:org:admin", display_name="Org Admin", orgs={}, ) + org_admin_perm.uuid = org_admin_perm_uuid + json_db._data.permissions[org_admin_perm_uuid] = org_admin_perm # Mapping from old permission ID to new permission UUID perm_id_to_uuid: dict[str, UUID] = {} @@ -113,11 +115,13 @@ async def migrate_from_sql( # Regular permission - create with UUID key perm_uuid: UUID = uuid7.create() - json_db._data.permissions[perm_uuid] = _PermissionData( + new_perm = Permission( scope=perm.id, # Old ID becomes the scope display_name=perm.display_name, orgs={}, ) + new_perm.uuid = perm_uuid + json_db._data.permissions[perm_uuid] = new_perm perm_id_to_uuid[perm.id] = perm_uuid print( f" Migrated {len(permissions)} permissions (with {len(org_admin_uuids)} org-specific admins consolidated to auth:org:admin)" @@ -181,18 +185,20 @@ async def migrate_from_sql( result = await session.execute(select(CredentialModel)) cred_models = result.scalars().all() for cm in cred_models: - cred = cm.as_dataclass() - cred_key: UUID = cred.uuid - json_db._data.credentials[cred_key] = _CredentialData( - credential_id=cred.credential_id, - user=cred.user_uuid, - aaguid=cred.aaguid, - public_key=cred.public_key, - sign_count=cred.sign_count, - created_at=cred.created_at, - last_used=cred.last_used, - last_verified=cred.last_verified, + legacy_cred = cm.as_dataclass() + cred_key: UUID = legacy_cred.uuid + new_cred = Credential( + credential_id=legacy_cred.credential_id, + user=legacy_cred.user_uuid, + aaguid=legacy_cred.aaguid, + public_key=legacy_cred.public_key, + sign_count=legacy_cred.sign_count, + created_at=legacy_cred.created_at, + last_used=legacy_cred.last_used, + last_verified=legacy_cred.last_verified, ) + new_cred.uuid = cred_key + json_db._data.credentials[cred_key] = new_cred print(f" Migrated {len(cred_models)} credentials") # Migrate sessions @@ -209,7 +215,7 @@ async def migrate_from_sql( else: # Already in new format or unknown - try to use as-is session_key = base64url.enc(old_key[:12]) - json_db._data.sessions[session_key] = _SessionData( + json_db._data.sessions[session_key] = Session( user=sess.user_uuid, credential=sess.credential_uuid, host=sess.host, @@ -233,7 +239,7 @@ async def migrate_from_sql( else: # Already in new format or unknown - truncate to 9 bytes token_key = old_key[:9] - json_db._data.reset_tokens[token_key] = _ResetTokenData( + json_db._data.reset_tokens[token_key] = ResetToken( user=token.user_uuid, expiry=token.expiry, token_type=token.token_type, diff --git a/paskia/migrate/sql.py b/paskia/migrate/sql.py index 7b5eb95..f37063f 100644 --- a/paskia/migrate/sql.py +++ b/paskia/migrate/sql.py @@ -26,10 +26,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from paskia.db import ( - Credential, Org, - ResetToken, - Role, ) @@ -46,6 +43,58 @@ class _LegacyUser: visits: int = 0 +# Legacy Credential class for SQL schema (uses 'user_uuid' not 'user') +@dataclass +class _LegacyCredential: + """Credential as stored in the old SQL schema with user_uuid field.""" + + uuid: UUID + credential_id: bytes + user_uuid: UUID + aaguid: UUID + public_key: bytes + sign_count: int + created_at: datetime + last_used: datetime | None = None + last_verified: datetime | None = None + + +# Legacy Role class for SQL schema (uses 'org_uuid' not 'org') +@dataclass +class _LegacyRole: + """Role as stored in the old SQL schema with org_uuid field.""" + + uuid: UUID + org_uuid: UUID + display_name: str + permissions: list[str] | None = None + + +# Legacy Session class for SQL schema (uses 'key' as field, 'user_uuid', 'credential_uuid') +@dataclass +class _LegacySession: + """Session as stored in the old SQL schema.""" + + key: bytes + user_uuid: UUID + credential_uuid: UUID + host: str + ip: str + user_agent: str + renewed: datetime + + +# Legacy ResetToken class for SQL schema (uses 'key' as field, 'user_uuid') +@dataclass +class _LegacyResetToken: + """ResetToken as stored in the old SQL schema.""" + + key: bytes + user_uuid: UUID + token_type: str + expiry: datetime + + # Local Permission class for SQL schema (uses 'id' not 'uuid' + 'scope') @dataclass class SqlPermission: @@ -58,20 +107,6 @@ class SqlPermission: DB_PATH_DEFAULT = "sqlite+aiosqlite:///paskia.sqlite" -# Local Session class for SQL schema (uses 'renewed' not 'expiry') -@dataclass -class _SqlSession: - """Session as stored in the old SQL schema with renewed timestamp.""" - - key: bytes - user_uuid: UUID - credential_uuid: UUID - host: str - ip: str - user_agent: str - renewed: datetime - - def _normalize_dt(value: datetime | None) -> datetime | None: if value is None: return None @@ -92,7 +127,9 @@ class OrgModel(Base): def as_dataclass(self): # Base Org without permissions/roles (filled by data accessors) - return Org(UUID(bytes=self.uuid), self.display_name) + org = Org(display_name=self.display_name) + org.uuid = UUID(bytes=self.uuid) + return org @staticmethod def from_dataclass(org: Org): @@ -110,14 +147,14 @@ class RoleModel(Base): def as_dataclass(self): # Base Role without permissions (filled by data accessors) - return Role( + return _LegacyRole( uuid=UUID(bytes=self.uuid), org_uuid=UUID(bytes=self.org_uuid), display_name=self.display_name, ) @staticmethod - def from_dataclass(role: Role): + def from_dataclass(role: _LegacyRole): return RoleModel( uuid=role.uuid.bytes, org_uuid=role.org_uuid.bytes, @@ -187,7 +224,7 @@ class CredentialModel(Base): ) def as_dataclass(self): - return Credential( + return _LegacyCredential( uuid=UUID(bytes=self.uuid), credential_id=self.credential_id, user_uuid=UUID(bytes=self.user_uuid), @@ -222,7 +259,7 @@ class SessionModel(Base): ) def as_dataclass(self): - return _SqlSession( + return _LegacySession( key=self.key, user_uuid=UUID(bytes=self.user_uuid), credential_uuid=UUID(bytes=self.credential_uuid), @@ -233,7 +270,7 @@ class SessionModel(Base): ) @staticmethod - def from_dataclass(session: _SqlSession): + def from_dataclass(session: _LegacySession): return SessionModel( key=session.key, user_uuid=session.user_uuid.bytes, @@ -255,8 +292,8 @@ class ResetTokenModel(Base): token_type: Mapped[str] = mapped_column(String, nullable=False) expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - def as_dataclass(self) -> ResetToken: - return ResetToken( + def as_dataclass(self) -> _LegacyResetToken: + return _LegacyResetToken( key=self.key, user_uuid=UUID(bytes=self.user_uuid), token_type=self.token_type, diff --git a/paskia/sansio.py b/paskia/sansio.py index dba462a..4032d0e 100644 --- a/paskia/sansio.py +++ b/paskia/sansio.py @@ -176,14 +176,12 @@ class Passkey: expected_origin=origin, expected_rp_id=self.rp_id, ) - return Credential( - uuid=uuid7.create(), + return Credential.create( credential_id=credential.raw_id, - user_uuid=user_uuid, + user=user_uuid, aaguid=UUID(registration.aaguid), public_key=registration.credential_public_key, sign_count=registration.sign_count, - created_at=datetime.now(timezone.utc), ) ### Authentication Methods ### diff --git a/tests/conftest.py b/tests/conftest.py index 0af7d11..7571ea9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -84,8 +84,7 @@ async def passkey_instance() -> Passkey: @pytest_asyncio.fixture(scope="function") async def test_org(test_db: DB, admin_permission: Permission) -> Org: """Create a test organization with admin permission.""" - org = Org( - uuid=uuid7.create(), + org = Org.create( display_name="Test Organization", permissions=[str(admin_permission.uuid)], # Org can grant this permission ) @@ -96,11 +95,7 @@ async def test_org(test_db: DB, admin_permission: Permission) -> Org: @pytest_asyncio.fixture(scope="function") async def admin_permission(test_db: DB) -> Permission: """Create the auth:admin permission.""" - import uuid7 - - perm = Permission( - uuid=uuid7.create(), scope="auth:admin", display_name="Master Admin" - ) + perm = Permission.create(scope="auth:admin", display_name="Master Admin") create_permission(perm) return perm @@ -108,11 +103,7 @@ async def admin_permission(test_db: DB) -> Permission: @pytest_asyncio.fixture(scope="function") async def org_admin_permission(test_db: DB, test_org: Org) -> Permission: """Create the auth:org:admin permission.""" - import uuid7 - - perm = Permission( - uuid=uuid7.create(), scope="auth:org:admin", display_name="Organization Admin" - ) + perm = Permission.create(scope="auth:org:admin", display_name="Organization Admin") create_permission(perm) # Make it grantable by the org add_permission_to_organization(str(test_org.uuid), "auth:org:admin") @@ -127,9 +118,8 @@ async def test_role( org_admin_permission: Permission, ) -> Role: """Create a test role with admin permission.""" - role = Role( - uuid=uuid7.create(), - org_uuid=test_org.uuid, + role = Role.create( + org=test_org.uuid, display_name="Test Admin Role", permissions=[str(admin_permission.uuid), str(org_admin_permission.uuid)], ) @@ -140,11 +130,9 @@ async def test_role( @pytest_asyncio.fixture(scope="function") async def user_role(test_db: DB, test_org: Org) -> Role: """Create a test role without admin permission (regular user).""" - role = Role( - uuid=uuid7.create(), - org_uuid=test_org.uuid, + role = Role.create( + org=test_org.uuid, display_name="User Role", - permissions=[], ) create_role(role) return role @@ -153,12 +141,9 @@ async def user_role(test_db: DB, test_org: Org) -> Role: @pytest_asyncio.fixture(scope="function") async def test_user(test_db: DB, test_role: Role) -> User: """Create a test user with admin role.""" - user = User( - uuid=uuid7.create(), + user = User.create( display_name="Test Admin", - role_uuid=test_role.uuid, - created_at=datetime.now(timezone.utc), - visits=0, + role=test_role.uuid, ) create_user(user) return user @@ -167,12 +152,9 @@ async def test_user(test_db: DB, test_role: Role) -> User: @pytest_asyncio.fixture(scope="function") async def regular_user(test_db: DB, user_role: Role) -> User: """Create a regular test user without admin permissions.""" - user = User( - uuid=uuid7.create(), + user = User.create( display_name="Regular User", - role_uuid=user_role.uuid, - created_at=datetime.now(timezone.utc), - visits=0, + role=user_role.uuid, ) create_user(user) return user @@ -181,16 +163,12 @@ async def regular_user(test_db: DB, user_role: Role) -> User: @pytest_asyncio.fixture(scope="function") async def test_credential(test_db: DB, test_user: User) -> Credential: """Create a test credential for the admin user.""" - credential = Credential( - uuid=uuid7.create(), + credential = Credential.create( credential_id=os.urandom(32), - user_uuid=test_user.uuid, + user=test_user.uuid, aaguid=UUID("00000000-0000-0000-0000-000000000000"), public_key=os.urandom(64), sign_count=0, - created_at=datetime.now(timezone.utc), - last_used=None, - last_verified=None, ) create_credential(credential) return credential @@ -199,16 +177,12 @@ async def test_credential(test_db: DB, test_user: User) -> Credential: @pytest_asyncio.fixture(scope="function") async def regular_credential(test_db: DB, regular_user: User) -> Credential: """Create a test credential for the regular user.""" - credential = Credential( - uuid=uuid7.create(), + credential = Credential.create( credential_id=os.urandom(32), - user_uuid=regular_user.uuid, + user=regular_user.uuid, aaguid=UUID("00000000-0000-0000-0000-000000000000"), public_key=os.urandom(64), sign_count=0, - created_at=datetime.now(timezone.utc), - last_used=None, - last_verified=None, ) create_credential(credential) return credential diff --git a/tests/test_admin.py b/tests/test_admin.py index 9d7c12a..fde45d8 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -43,10 +43,8 @@ from tests.conftest import auth_headers @pytest_asyncio.fixture(scope="function") async def second_org(test_db: DB) -> Org: """Create a second organization for deletion tests.""" - org = Org( - uuid=uuid7.create(), + org = Org.create( display_name="Second Organization", - permissions=[], ) create_organization(org) return org @@ -57,9 +55,8 @@ async def second_org_role( test_db: DB, second_org: Org, admin_permission: Permission ) -> Role: """Create a role in the second org with admin permission.""" - role = Role( - uuid=uuid7.create(), - org_uuid=second_org.uuid, + role = Role.create( + org=second_org.uuid, display_name="Second Org Admin Role", permissions=[str(admin_permission.uuid)], ) @@ -70,12 +67,9 @@ async def second_org_role( @pytest_asyncio.fixture(scope="function") async def second_org_user(test_db: DB, second_org_role: Role) -> User: """Create a user in the second org.""" - user = User( - uuid=uuid7.create(), + user = User.create( display_name="Second Org User", - role_uuid=second_org_role.uuid, - created_at=datetime.now(timezone.utc), - visits=0, + role=second_org_role.uuid, ) create_user(user) return user @@ -86,16 +80,12 @@ async def second_org_credential(test_db: DB, second_org_user: User) -> Credentia """Create a credential for the second org user.""" import os - credential = Credential( - uuid=uuid7.create(), + credential = Credential.create( credential_id=os.urandom(32), - user_uuid=second_org_user.uuid, + user=second_org_user.uuid, aaguid=UUID("00000000-0000-0000-0000-000000000000"), public_key=os.urandom(64), sign_count=0, - created_at=datetime.now(timezone.utc), - last_used=datetime.now(timezone.utc), - last_verified=datetime.now(timezone.utc), ) create_credential(credential) return credential @@ -124,9 +114,8 @@ async def org_admin_role( test_db: DB, test_org: Org, org_admin_permission: Permission ) -> Role: """Create a role with org admin permission only (no global admin).""" - role = Role( - uuid=uuid7.create(), - org_uuid=test_org.uuid, + role = Role.create( + org=test_org.uuid, display_name="Org Admin Role", permissions=[str(org_admin_permission.uuid)], ) @@ -137,14 +126,12 @@ async def org_admin_role( @pytest_asyncio.fixture(scope="function") async def org_admin_user(test_db: DB, org_admin_role: Role) -> User: """Create a user with org admin permission only.""" - user = User( - uuid=uuid7.create(), + user = User.create( display_name="Org Admin User", - role_uuid=org_admin_role.uuid, - created_at=datetime.now(timezone.utc), - visits=5, - last_seen=datetime.now(timezone.utc), + role=org_admin_role.uuid, ) + user.visits = 5 + user.last_seen = datetime.now(timezone.utc) create_user(user) return user @@ -154,16 +141,12 @@ async def org_admin_credential(test_db: DB, org_admin_user: User) -> Credential: """Create a credential for the org admin user.""" import os - credential = Credential( - uuid=uuid7.create(), + credential = Credential.create( credential_id=os.urandom(32), - user_uuid=org_admin_user.uuid, + user=org_admin_user.uuid, aaguid=UUID("00000000-0000-0000-0000-000000000000"), public_key=os.urandom(64), sign_count=0, - created_at=datetime.now(timezone.utc), - last_used=datetime.now(timezone.utc), - last_verified=None, ) create_credential(credential) return credential @@ -190,11 +173,7 @@ async def org_admin_session_token( @pytest_asyncio.fixture(scope="function") async def grantable_permission(test_db: DB, test_org: Org) -> Permission: """Create a permission and add it to org's grantable permissions.""" - import uuid7 - - perm = Permission( - uuid=uuid7.create(), scope="test:grantable:perm", display_name="Grantable Perm" - ) + perm = Permission.create(scope="test:grantable:perm", display_name="Grantable Perm") create_permission(perm) # Add to org's grantable permissions add_permission_to_organization(str(test_org.uuid), perm.scope) @@ -418,21 +397,16 @@ class TestAdminOrganizations: client: httpx.AsyncClient, session_token: str, test_db: DB, - ): + ): """Admin should be able to delete another organization.""" - import uuid7 - # Create org to delete - org_to_delete = Org( - uuid=uuid7.create(), + org_to_delete = Org.create( display_name="Org To Delete", - permissions=[], ) create_organization(org_to_delete) # Create some org-specific permissions to test cleanup - org_perm = Permission( - uuid=uuid7.create(), + org_perm = Permission.create( scope=f"test:org:{org_to_delete.uuid}:feature", display_name="Org Feature", ) @@ -608,10 +582,7 @@ class TestAdminRoles: ): """Creating role with non-grantable permission should fail.""" # Create permission but don't add to org - import uuid7 - - perm = Permission( - uuid=uuid7.create(), + perm = Permission.create( scope="test:not:grantable", display_name="Not Grantable", ) @@ -683,10 +654,7 @@ class TestAdminRoles: test_db: DB, ): """Adding non-grantable permission to role should fail.""" - import uuid7 - - perm = Permission( - uuid=uuid7.create(), + perm = Permission.create( scope="test:not:grantable:update", display_name="Not Grantable", ) @@ -1110,12 +1078,9 @@ class TestAdminUsersInOrg: ): """Creating link for user without credentials should return registration link.""" # Create user without credentials - user_no_cred = User( - uuid=uuid7.create(), + user_no_cred = User.create( display_name="User Without Creds", - role_uuid=user_role.uuid, - created_at=datetime.now(timezone.utc), - visits=0, + role=user_role.uuid, ) create_user(user_no_cred) @@ -1391,11 +1356,7 @@ class TestAdminPermissions: ): """Admin should be able to update a permission.""" # Create permission first - import uuid7 - - perm = Permission( - uuid=uuid7.create(), scope="test:updateable", display_name="Updateable" - ) + perm = Permission.create(scope="test:updateable", display_name="Updateable") create_permission(perm) response = await client.patch( @@ -1412,11 +1373,7 @@ class TestAdminPermissions: ): """Updating permission with empty name should fail.""" # Create permission first - import uuid7 - - perm = Permission( - uuid=uuid7.create(), scope="test:perm", display_name="Test Perm" - ) + perm = Permission.create(scope="test:perm", display_name="Test Perm") create_permission(perm) response = await client.patch( @@ -1433,11 +1390,7 @@ class TestAdminPermissions: ): """Admin should be able to rename a permission.""" # Create permission first - import uuid7 - - perm = Permission( - uuid=uuid7.create(), scope="test:renameable2", display_name="Renameable" - ) + perm = Permission.create(scope="test:renameable2", display_name="Renameable") create_permission(perm) response = await client.post( @@ -1480,11 +1433,7 @@ class TestAdminPermissions: self, client: httpx.AsyncClient, session_token: str, test_db: DB ): """Renaming permission can also update display name.""" - import uuid7 - - perm = Permission( - uuid=uuid7.create(), scope="test:rename:withname", display_name="Old Name" - ) + perm = Permission.create(scope="test:rename:withname", display_name="Old Name") create_permission(perm) response = await client.post( @@ -1504,11 +1453,7 @@ class TestAdminPermissions: ): """Admin should be able to delete a permission.""" # Create permission first - import uuid7 - - perm = Permission( - uuid=uuid7.create(), scope="test:deleteable", display_name="Deleteable" - ) + perm = Permission.create(scope="test:deleteable", display_name="Deleteable") create_permission(perm) response = await client.delete( @@ -1537,14 +1482,10 @@ class TestAdminPermissions: self, client: httpx.AsyncClient, session_token: str, test_db: DB ): """Can delete an auth:admin permission if another accessible one exists.""" - import uuid7 - from paskia.db import Permission # Create a second auth:admin permission (no domain restriction) - perm2 = Permission( - uuid=uuid7.create(), scope="auth:admin", display_name="Secondary Admin" - ) + perm2 = Permission.create(scope="auth:admin", display_name="Secondary Admin") create_permission(perm2) # Now we can delete the original one @@ -1561,13 +1502,10 @@ class TestAdminPermissions: self, client: httpx.AsyncClient, session_token: str, test_db: DB ): """Cannot delete auth:admin if remaining one has mismatched domain.""" - import uuid7 - from paskia.db import Permission # Create a second auth:admin permission with a different domain - perm2 = Permission( - uuid=uuid7.create(), + perm2 = Permission.create( scope="auth:admin", display_name="Other Domain Admin", domain="other.example.com", diff --git a/tests/test_api.py b/tests/test_api.py index 00b5ca2..5caef92 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -76,7 +76,9 @@ class TestValidateEndpoint: assert response.status_code == 200 data = response.json() assert data["valid"] is True - assert "user_uuid" in data + assert "ctx" in data + assert "user" in data["ctx"] + assert "uuid" in data["ctx"]["user"] @pytest.mark.asyncio async def test_validate_with_permission_check( @@ -243,9 +245,9 @@ class TestUserInfoEndpoint: ) assert response.status_code == 200 data = response.json() - assert "user" in data - assert data["user"]["user_uuid"] == str(test_user.uuid) - assert data["user"]["user_name"] == test_user.display_name + assert "ctx" in data + assert data["ctx"]["user"]["uuid"] == str(test_user.uuid) + assert data["ctx"]["user"]["display_name"] == test_user.display_name @pytest.mark.asyncio async def test_user_info_includes_credentials( @@ -286,7 +288,8 @@ class TestUserInfoEndpoint: ) assert response.status_code == 200 data = response.json() - assert "permissions" in data + assert "ctx" in data + assert "permissions" in data["ctx"] class TestSetSessionEndpoint: @@ -392,47 +395,44 @@ class TestForwardAuthHtmlResponse: assert data["auth"]["mode"] == "login" -class TestUserInfoWithResetToken: - """Tests for user-info endpoint with reset tokens""" +class TestTokenInfoEndpoint: + """Tests for token-info endpoint with reset tokens""" @pytest.mark.asyncio - async def test_user_info_with_invalid_reset_token(self, client: httpx.AsyncClient): - """User info with invalid reset token format should return 401.""" - # Invalid format - not a well-formed passphrase (wrong separator) - response = await client.post( - "/auth/api/user-info?reset=invalid-token-format", + async def test_token_info_with_invalid_token(self, client: httpx.AsyncClient): + """Token info with invalid token format should return 400.""" + response = await client.get( + "/auth/api/token-info", + headers={"Authorization": "Bearer invalid-token-format"}, ) - # Invalid format raises ValueError which gets converted to 401 HTTPException - assert response.status_code == 401 - data = response.json() - assert "Invalid reset token" in data["detail"] + assert response.status_code == 400 @pytest.mark.asyncio - async def test_user_info_with_nonexistent_reset_token( - self, client: httpx.AsyncClient - ): - """User info with well-formed but non-existent reset token should return 401.""" - # We need a well-formed passphrase that doesn't exist in DB + async def test_token_info_with_nonexistent_token(self, client: httpx.AsyncClient): + """Token info with well-formed but non-existent token should return 401.""" from paskia.util.passphrase import generate - fake_token = generate() # Generates a well-formed token - response = await client.post( - f"/auth/api/user-info?reset={fake_token}", + fake_token = generate() + response = await client.get( + "/auth/api/token-info", + headers={"Authorization": f"Bearer {fake_token}"}, ) - # Should return 401 for non-existent token assert response.status_code == 401 @pytest.mark.asyncio - async def test_user_info_with_valid_reset_token( + async def test_token_info_with_valid_token( self, client: httpx.AsyncClient, reset_token: str, test_user ): - """User info with valid reset token should return minimal user info.""" - response = await client.post( - f"/auth/api/user-info?reset={reset_token}", + """Token info with valid reset token should return token type and display name.""" + response = await client.get( + "/auth/api/token-info", + headers={"Authorization": f"Bearer {reset_token}"}, ) assert response.status_code == 200 data = response.json() - assert "user" in data + assert "token_type" in data + assert "display_name" in data + assert data["display_name"] == test_user.display_name class TestSetSessionErrors: