diff --git a/gateway/src/routes/oidc/logout.test.ts b/gateway/src/routes/oidc/logout.test.ts new file mode 100644 --- /dev/null +++ b/gateway/src/routes/oidc/logout.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import crypto from 'crypto'; +import { createLogoutRouter } from './logout.js'; +import { DatabaseService } from '../../services/database.js'; + +function createMockOIDCService(verifyResult: any = null) { + return { + tokenService: { + verifyIdToken: vi.fn().mockReturnValue(verifyResult), + verifyAccessToken: vi.fn(), + createTokenResponse: vi.fn(), + }, + keyService: { getPublicKeySet: vi.fn() }, + } as any; +} + +function createTestApp(db: DatabaseService, oidcService: any) { + const app = express(); + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + const router = createLogoutRouter(db, oidcService); + app.use('/oauth', router); + + return app; +} + +function registerOIDCClient(db: DatabaseService, id = 'test-client') { + db.upsertOIDCClient({ + id, + name: 'Test App', + client_type: 'oidc', + hmac_secret: crypto.randomBytes(32).toString('hex'), + redirect_uris: ['https://app.example.com/callback'], + grant_types: ['authorization_code'], + allowed_scopes: ['openid', 'profile'], + token_ttl_seconds: 3600, + id_token_ttl_seconds: 3600, + access_token_ttl_seconds: 3600, + refresh_token_ttl_seconds: 86400, + require_pkce: false, + token_endpoint_auth_method: 'client_secret_basic', + }); +} + +describe('GET /oauth/end_session', () => { + let db: DatabaseService; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + }); + + afterEach(() => db.close()); + + it('should render logged out page when no redirect URI', async () => { + const oidcService = createMockOIDCService(); + const app = createTestApp(db, oidcService); + + const res = await request(app).get('/oauth/end_session'); + + expect(res.status).toBe(200); + expect(res.text).toContain('Logged Out'); + expect(res.text).toContain('successfully logged out'); + }); + + it('should redirect to post_logout_redirect_uri when valid', async () => { + registerOIDCClient(db); + const oidcService = createMockOIDCService(); + const app = createTestApp(db, oidcService); + + const res = await request(app) + .get('/oauth/end_session') + .query({ + client_id: 'test-client', + post_logout_redirect_uri: 'https://app.example.com/callback', + }); + + expect(res.status).toBe(302); + expect(res.headers.location).toContain('app.example.com/callback'); + }); + + it('should append state to redirect', async () => { + registerOIDCClient(db); + const oidcService = createMockOIDCService(); + const app = createTestApp(db, oidcService); + + const res = await request(app) + .get('/oauth/end_session') + .query({ + client_id: 'test-client', + post_logout_redirect_uri: 'https://app.example.com/callback', + state: 'my-state-123', + }); + + expect(res.status).toBe(302); + expect(res.headers.location).toContain('state=my-state-123'); + }); + + it('should reject invalid post_logout_redirect_uri', async () => { + registerOIDCClient(db); + const oidcService = createMockOIDCService(); + const app = createTestApp(db, oidcService); + + const res = await request(app) + .get('/oauth/end_session') + .query({ + client_id: 'test-client', + post_logout_redirect_uri: 'https://evil.example.com/steal', + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('invalid_request'); + expect(res.body.error_description).toContain('Invalid post_logout_redirect_uri'); + }); + + it('should reject unknown client_id', async () => { + const oidcService = createMockOIDCService(); + const app = createTestApp(db, oidcService); + + const res = await request(app) + .get('/oauth/end_session') + .query({ + client_id: 'nonexistent', + post_logout_redirect_uri: 'https://app.example.com', + }); + + expect(res.status).toBe(400); + expect(res.body.error_description).toContain('Unknown client'); + }); + + it('should extract client_id from id_token_hint', async () => { + registerOIDCClient(db); + const oidcService = createMockOIDCService({ sub: 'did:plc:test', aud: 'test-client' }); + const app = createTestApp(db, oidcService); + + const res = await request(app) + .get('/oauth/end_session') + .query({ + id_token_hint: 'mock-id-token', + post_logout_redirect_uri: 'https://app.example.com/callback', + }); + + expect(res.status).toBe(302); + expect(oidcService.tokenService.verifyIdToken).toHaveBeenCalledWith('mock-id-token'); + }); + + it('should revoke refresh tokens on logout with id_token_hint', async () => { + registerOIDCClient(db); + const oidcService = createMockOIDCService({ sub: 'did:plc:test', aud: 'test-client' }); + const app = createTestApp(db, oidcService); + + const revokeSpy = vi.spyOn(db, 'revokeAllRefreshTokensForUser'); + + await request(app) + .get('/oauth/end_session') + .query({ + id_token_hint: 'mock-id-token', + }); + + expect(revokeSpy).toHaveBeenCalledWith('did:plc:test', 'test-client'); + }); +}); diff --git a/gateway/src/routes/oidc/revoke.test.ts b/gateway/src/routes/oidc/revoke.test.ts new file mode 100644 --- /dev/null +++ b/gateway/src/routes/oidc/revoke.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import crypto from 'crypto'; +import { createRevokeRouter } from './revoke.js'; +import { DatabaseService } from '../../services/database.js'; + +function createTestApp(db: DatabaseService) { + const app = express(); + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + const router = createRevokeRouter(db); + app.use('/oauth', router); + + return app; +} + +function registerOIDCClient(db: DatabaseService, id = 'test-client', secret = 'my-secret') { + const secretHash = crypto.createHash('sha256').update(secret).digest('hex'); + db.upsertOIDCClient({ + id, + name: 'Test App', + client_type: 'oidc', + hmac_secret: crypto.randomBytes(32).toString('hex'), + client_secret: secretHash, + redirect_uris: ['https://app.example.com/callback'], + grant_types: ['authorization_code', 'refresh_token'], + allowed_scopes: ['openid', 'profile'], + token_ttl_seconds: 3600, + id_token_ttl_seconds: 3600, + access_token_ttl_seconds: 3600, + refresh_token_ttl_seconds: 86400, + require_pkce: false, + token_endpoint_auth_method: 'client_secret_basic', + }); +} + +function createRefreshToken(db: DatabaseService, token: string, clientId = 'test-client') { + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + db.saveRefreshToken({ + token_hash: tokenHash, + client_id: clientId, + did: 'did:plc:testuser', + handle: 'test.bsky.social', + scope: 'openid profile', + expires_at: new Date(Date.now() + 86400 * 1000), + family_id: `family-${Date.now()}`, + }); + return tokenHash; +} + +describe('POST /oauth/revoke', () => { + let db: DatabaseService; + let app: express.Application; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + app = createTestApp(db); + registerOIDCClient(db); + }); + + afterEach(() => db.close()); + + it('should return 200 for missing token (per RFC 7009)', async () => { + const res = await request(app) + .post('/oauth/revoke') + .send({}); + + expect(res.status).toBe(200); + }); + + it('should revoke a refresh token', async () => { + const rawToken = 'test-refresh-token-123'; + const tokenHash = createRefreshToken(db, rawToken); + + const res = await request(app) + .post('/oauth/revoke') + .send({ token: rawToken, client_id: 'test-client', client_secret: 'my-secret' }); + + expect(res.status).toBe(200); + + // Token should be revoked in DB + const stored = db.getRefreshToken(tokenHash); + expect(stored?.revoked).toBe(true); + }); + + it('should accept client credentials via Basic auth', async () => { + const rawToken = 'test-refresh-token-basic'; + createRefreshToken(db, rawToken); + + const credentials = Buffer.from('test-client:my-secret').toString('base64'); + const res = await request(app) + .post('/oauth/revoke') + .set('Authorization', `Basic ${credentials}`) + .send({ token: rawToken }); + + expect(res.status).toBe(200); + }); + + it('should return 401 for unknown client', async () => { + const res = await request(app) + .post('/oauth/revoke') + .send({ token: 'some-token', client_id: 'nonexistent' }); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('invalid_client'); + }); + + it('should return 401 for wrong client secret', async () => { + const res = await request(app) + .post('/oauth/revoke') + .send({ token: 'some-token', client_id: 'test-client', client_secret: 'wrong-secret' }); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('invalid_client'); + }); + + it('should return 401 for missing client secret when required', async () => { + const res = await request(app) + .post('/oauth/revoke') + .send({ token: 'some-token', client_id: 'test-client' }); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('invalid_client'); + expect(res.body.error_description).toContain('Client authentication required'); + }); + + it('should return 200 for unknown token (per RFC 7009)', async () => { + const res = await request(app) + .post('/oauth/revoke') + .send({ token: 'nonexistent-token', client_id: 'test-client', client_secret: 'my-secret' }); + + expect(res.status).toBe(200); + }); + + it('should return 200 for token belonging to different client', async () => { + // Register a second client so FK constraint is satisfied + registerOIDCClient(db, 'other-client', 'other-secret'); + + const rawToken = 'other-client-token'; + createRefreshToken(db, rawToken, 'other-client'); + + const res = await request(app) + .post('/oauth/revoke') + .send({ token: rawToken, client_id: 'test-client', client_secret: 'my-secret' }); + + // Per RFC 7009, still returns 200 + expect(res.status).toBe(200); + }); + + it('should handle access token revocation (returns 200)', async () => { + const res = await request(app) + .post('/oauth/revoke') + .send({ + token: 'some-access-token', + token_type_hint: 'access_token', + client_id: 'test-client', + client_secret: 'my-secret', + }); + + // Access tokens can't be truly revoked (JWT), returns 200 per RFC + expect(res.status).toBe(200); + }); +}); diff --git a/gateway/src/routes/oidc/userinfo.test.ts b/gateway/src/routes/oidc/userinfo.test.ts new file mode 100644 --- /dev/null +++ b/gateway/src/routes/oidc/userinfo.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { createUserInfoRouter } from './userinfo.js'; +import { DatabaseService } from '../../services/database.js'; + +function createMockOIDCService(accessTokenClaims: any = null) { + return { + tokenService: { + verifyAccessToken: vi.fn().mockReturnValue(accessTokenClaims), + verifyIdToken: vi.fn(), + createTokenResponse: vi.fn(), + }, + keyService: { getPublicKeySet: vi.fn() }, + } as any; +} + +function createTestApp(db: DatabaseService, oidcService: any) { + const app = express(); + app.use(express.json()); + + const router = createUserInfoRouter(db, oidcService); + app.use('/oauth', router); + + return app; +} + +describe('/oauth/userinfo', () => { + let db: DatabaseService; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + // Mock the global fetch for AT Protocol API calls + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ handle: 'test.bsky.social' }), + } as Response); + }); + + afterEach(() => { + db.close(); + vi.restoreAllMocks(); + }); + + it('should return 401 for missing Authorization header', async () => { + const oidcService = createMockOIDCService(); + const app = createTestApp(db, oidcService); + + const res = await request(app).get('/oauth/userinfo'); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('invalid_token'); + }); + + it('should return 401 for non-Bearer auth', async () => { + const oidcService = createMockOIDCService(); + const app = createTestApp(db, oidcService); + + const res = await request(app) + .get('/oauth/userinfo') + .set('Authorization', 'Basic abc123'); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('invalid_token'); + }); + + it('should return 401 for invalid access token', async () => { + const oidcService = createMockOIDCService(null); // null = invalid token + const app = createTestApp(db, oidcService); + + const res = await request(app) + .get('/oauth/userinfo') + .set('Authorization', 'Bearer invalid-token'); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('invalid_token'); + expect(res.body.error_description).toContain('expired'); + }); + + it('should return user info for valid token with openid scope', async () => { + const oidcService = createMockOIDCService({ + sub: 'did:plc:testuser', + scope: 'openid', + client_id: 'test-client', + }); + const app = createTestApp(db, oidcService); + + const res = await request(app) + .get('/oauth/userinfo') + .set('Authorization', 'Bearer valid-token'); + + expect(res.status).toBe(200); + expect(res.body.sub).toBe('did:plc:testuser'); + }); + + it('should return profile claims for profile scope', async () => { + const oidcService = createMockOIDCService({ + sub: 'did:plc:testuser', + scope: 'openid profile', + client_id: 'test-client', + }); + const app = createTestApp(db, oidcService); + + const res = await request(app) + .get('/oauth/userinfo') + .set('Authorization', 'Bearer valid-token'); + + expect(res.status).toBe(200); + expect(res.body.sub).toBe('did:plc:testuser'); + expect(res.body.preferred_username).toBe('test.bsky.social'); + }); + + it('should support POST method', async () => { + const oidcService = createMockOIDCService({ + sub: 'did:plc:testuser', + scope: 'openid', + client_id: 'test-client', + }); + const app = createTestApp(db, oidcService); + + const res = await request(app) + .post('/oauth/userinfo') + .set('Authorization', 'Bearer valid-token'); + + expect(res.status).toBe(200); + expect(res.body.sub).toBe('did:plc:testuser'); + }); + + it('should use user mapping handle when available', async () => { + const oidcService = createMockOIDCService({ + sub: 'did:plc:testuser', + scope: 'openid profile', + client_id: 'test-app', + }); + + // Register app and create user mapping + db.upsertApp({ + id: 'test-app', + name: 'Test', + hmac_secret: 'a'.repeat(64), + token_ttl_seconds: 3600, + }); + db.setUserMapping({ + did: 'did:plc:testuser', + app_id: 'test-app', + user_id: 1, + handle: 'mapped.handle.social', + }); + + const app = createTestApp(db, oidcService); + + const res = await request(app) + .get('/oauth/userinfo') + .set('Authorization', 'Bearer valid-token'); + + expect(res.status).toBe(200); + expect(res.body.preferred_username).toBe('mapped.handle.social'); + // Should NOT have called fetch since mapping was found + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('should fall back to DID when API call fails', async () => { + vi.spyOn(global, 'fetch').mockRejectedValue(new Error('network error')); + + const oidcService = createMockOIDCService({ + sub: 'did:plc:testuser', + scope: 'openid profile', + client_id: 'test-client', + }); + const app = createTestApp(db, oidcService); + + const res = await request(app) + .get('/oauth/userinfo') + .set('Authorization', 'Bearer valid-token'); + + expect(res.status).toBe(200); + expect(res.body.preferred_username).toBe('did:plc:testuser'); + }); +}); diff --git a/gateway/src/services/passkey.test.ts b/gateway/src/services/passkey.test.ts new file mode 100644 --- /dev/null +++ b/gateway/src/services/passkey.test.ts @@ -0,0 +1,371 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { PasskeyService } from './passkey.js'; +import { DatabaseService } from './database.js'; + +// Mock @simplewebauthn/server +vi.mock('@simplewebauthn/server', () => ({ + generateRegistrationOptions: vi.fn().mockResolvedValue({ + challenge: 'mock-challenge-registration', + rp: { name: 'Test', id: 'localhost' }, + user: { id: 'dGVzdA', name: 'test', displayName: 'test' }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' }, + }), + verifyRegistrationResponse: vi.fn().mockResolvedValue({ + verified: true, + registrationInfo: { + credentialID: 'cred-id-123', + credentialPublicKey: Buffer.from('public-key-bytes'), + counter: 0, + credentialDeviceType: 'singleDevice', + credentialBackedUp: false, + }, + }), + generateAuthenticationOptions: vi.fn().mockResolvedValue({ + challenge: 'mock-challenge-authentication', + rpId: 'localhost', + allowCredentials: [], + userVerification: 'preferred', + }), + verifyAuthenticationResponse: vi.fn().mockResolvedValue({ + verified: true, + authenticationInfo: { + newCounter: 1, + credentialID: 'cred-id-123', + }, + }), +})); + +const PASSKEY_CONFIG = { + rpName: 'Test RP', + rpID: 'localhost', + origin: 'http://localhost:3000', +}; + +describe('PasskeyService', () => { + let db: DatabaseService; + let service: PasskeyService; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + service = new PasskeyService(db, PASSKEY_CONFIG); + }); + + afterEach(() => { + db.close(); + vi.clearAllMocks(); + }); + + describe('generateRegistrationOptions', () => { + it('should return registration options', async () => { + const options = await service.generateRegistrationOptions('did:plc:test', 'test.bsky.social'); + expect(options).toBeDefined(); + expect(options.challenge).toBe('mock-challenge-registration'); + }); + + it('should exclude existing credentials', async () => { + // Save an existing credential + db.savePasskeyCredential({ + id: 'existing-cred', + did: 'did:plc:test', + handle: 'test.bsky.social', + public_key: Buffer.from('key').toString('base64'), + counter: 0, + device_type: 'platform', + backed_up: false, + transports: null, + name: null, + }); + + await service.generateRegistrationOptions('did:plc:test', 'test.bsky.social'); + + const { generateRegistrationOptions } = await import('@simplewebauthn/server'); + expect(generateRegistrationOptions).toHaveBeenCalledWith( + expect.objectContaining({ + excludeCredentials: expect.arrayContaining([ + expect.objectContaining({ id: 'existing-cred' }), + ]), + }) + ); + }); + }); + + describe('verifyRegistration', () => { + it('should verify and store a credential', async () => { + // First generate options to store challenge + await service.generateRegistrationOptions('did:plc:test', 'test.bsky.social'); + + const result = await service.verifyRegistration( + 'did:plc:test', + 'test.bsky.social', + { id: 'cred-1', rawId: 'raw', response: { clientDataJSON: 'x', attestationObject: 'y', transports: ['internal'] }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' }, + 'My Passkey', + ); + + expect(result.success).toBe(true); + expect(result.credentialId).toBe('cred-id-123'); + + // Credential should be stored in DB + const stored = db.getPasskeyCredential('cred-id-123'); + expect(stored).not.toBeNull(); + expect(stored!.did).toBe('did:plc:test'); + }); + + it('should return error when no challenge exists', async () => { + const result = await service.verifyRegistration( + 'did:plc:unknown', + 'unknown', + { id: 'x', rawId: 'x', response: { clientDataJSON: 'x', attestationObject: 'y' }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' } as any, + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('No registration challenge'); + }); + + it('should return error when challenge is expired', async () => { + vi.useFakeTimers(); + const now = Date.now(); + vi.setSystemTime(now); + + await service.generateRegistrationOptions('did:plc:test', 'test.bsky.social'); + + // Advance past 5 minute expiry + vi.setSystemTime(now + 6 * 60 * 1000); + + const result = await service.verifyRegistration( + 'did:plc:test', + 'test.bsky.social', + { id: 'x', rawId: 'x', response: { clientDataJSON: 'x', attestationObject: 'y' }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' } as any, + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('expired'); + + vi.useRealTimers(); + }); + }); + + describe('generateAuthenticationOptions', () => { + it('should generate options without DID (discoverable)', async () => { + const options = await service.generateAuthenticationOptions(); + expect(options).toBeDefined(); + expect(options.challenge).toBe('mock-challenge-authentication'); + }); + + it('should include credentials when DID provided', async () => { + db.savePasskeyCredential({ + id: 'user-cred', + did: 'did:plc:test', + handle: 'test.bsky.social', + public_key: Buffer.from('key').toString('base64'), + counter: 0, + device_type: 'platform', + backed_up: false, + transports: ['internal'], + name: null, + }); + + await service.generateAuthenticationOptions('did:plc:test'); + + const { generateAuthenticationOptions } = await import('@simplewebauthn/server'); + expect(generateAuthenticationOptions).toHaveBeenCalledWith( + expect.objectContaining({ + allowCredentials: expect.arrayContaining([ + expect.objectContaining({ id: 'user-cred' }), + ]), + }) + ); + }); + }); + + describe('verifyAuthentication', () => { + it('should verify and return user info', async () => { + // Store credential + db.savePasskeyCredential({ + id: 'cred-id-123', + did: 'did:plc:test', + handle: 'test.bsky.social', + public_key: Buffer.from('key').toString('base64'), + counter: 0, + device_type: 'platform', + backed_up: false, + transports: null, + name: null, + }); + + // Generate options to store challenge + await service.generateAuthenticationOptions(); + + const result = await service.verifyAuthentication( + { id: 'cred-id-123', rawId: 'raw', response: { clientDataJSON: 'x', authenticatorData: 'y', signature: 'z' }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' }, + 'mock-challenge-authentication', + ); + + expect(result.success).toBe(true); + expect(result.did).toBe('did:plc:test'); + expect(result.handle).toBe('test.bsky.social'); + }); + + it('should return error for unknown credential', async () => { + await service.generateAuthenticationOptions(); + + const result = await service.verifyAuthentication( + { id: 'unknown-cred', rawId: 'raw', response: { clientDataJSON: 'x', authenticatorData: 'y', signature: 'z' }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' }, + 'mock-challenge-authentication', + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('Unknown credential'); + }); + + it('should return error when no challenge exists', async () => { + const result = await service.verifyAuthentication( + { id: 'x', rawId: 'raw', response: { clientDataJSON: 'x', authenticatorData: 'y', signature: 'z' }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' }, + 'nonexistent-challenge', + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('No authentication challenge'); + }); + }); + + describe('listPasskeys', () => { + it('should list passkeys for a user', () => { + db.savePasskeyCredential({ + id: 'cred-1', + did: 'did:plc:test', + handle: 'test.bsky.social', + public_key: Buffer.from('key1').toString('base64'), + counter: 0, + device_type: 'platform', + backed_up: true, + transports: ['internal'], + name: 'My Macbook', + }); + + db.savePasskeyCredential({ + id: 'cred-2', + did: 'did:plc:test', + handle: 'test.bsky.social', + public_key: Buffer.from('key2').toString('base64'), + counter: 0, + device_type: 'cross-platform', + backed_up: false, + transports: ['usb'], + name: 'YubiKey', + }); + + const passkeys = service.listPasskeys('did:plc:test'); + expect(passkeys).toHaveLength(2); + expect(passkeys[0].name).toBe('My Macbook'); + expect(passkeys[1].name).toBe('YubiKey'); + }); + + it('should return empty array for user with no passkeys', () => { + const passkeys = service.listPasskeys('did:plc:nobody'); + expect(passkeys).toEqual([]); + }); + }); + + describe('renamePasskey', () => { + it('should rename an existing passkey', () => { + db.savePasskeyCredential({ + id: 'cred-1', + did: 'did:plc:test', + handle: 'test.bsky.social', + public_key: Buffer.from('key').toString('base64'), + counter: 0, + device_type: 'platform', + backed_up: false, + transports: null, + name: 'Old Name', + }); + + const result = service.renamePasskey('did:plc:test', 'cred-1', 'New Name'); + expect(result).toBe(true); + }); + + it('should return false for wrong DID', () => { + db.savePasskeyCredential({ + id: 'cred-1', + did: 'did:plc:other', + handle: 'other.bsky.social', + public_key: Buffer.from('key').toString('base64'), + counter: 0, + device_type: 'platform', + backed_up: false, + transports: null, + name: null, + }); + + const result = service.renamePasskey('did:plc:test', 'cred-1', 'New Name'); + expect(result).toBe(false); + }); + + it('should return false for nonexistent credential', () => { + const result = service.renamePasskey('did:plc:test', 'nonexistent', 'Name'); + expect(result).toBe(false); + }); + }); + + describe('deletePasskey', () => { + it('should delete an existing passkey', () => { + db.savePasskeyCredential({ + id: 'cred-1', + did: 'did:plc:test', + handle: 'test.bsky.social', + public_key: Buffer.from('key').toString('base64'), + counter: 0, + device_type: 'platform', + backed_up: false, + transports: null, + name: null, + }); + + const result = service.deletePasskey('did:plc:test', 'cred-1'); + expect(result).toBe(true); + expect(db.getPasskeyCredential('cred-1')).toBeNull(); + }); + + it('should return false for wrong DID', () => { + db.savePasskeyCredential({ + id: 'cred-1', + did: 'did:plc:other', + handle: 'other', + public_key: Buffer.from('key').toString('base64'), + counter: 0, + device_type: 'platform', + backed_up: false, + transports: null, + name: null, + }); + + const result = service.deletePasskey('did:plc:test', 'cred-1'); + expect(result).toBe(false); + }); + }); + + describe('hasPasskeys / getPasskeyCount', () => { + it('should return false and 0 for user with no passkeys', () => { + expect(service.hasPasskeys('did:plc:nobody')).toBe(false); + expect(service.getPasskeyCount('did:plc:nobody')).toBe(0); + }); + + it('should return true and correct count', () => { + db.savePasskeyCredential({ + id: 'cred-1', + did: 'did:plc:test', + handle: 'test', + public_key: Buffer.from('key').toString('base64'), + counter: 0, + device_type: 'platform', + backed_up: false, + transports: null, + name: null, + }); + + expect(service.hasPasskeys('did:plc:test')).toBe(true); + expect(service.getPasskeyCount('did:plc:test')).toBe(1); + }); + }); +});