返回 AiToEarn
auth.service.spec.ts
根目录 / project / aitoearn-backend / apps / aitoearn-server / src / core / channels / auth / auth.service.spec.ts
1 import type { GenerateAuthUrlInput } from '../platforms/platforms.interface'
2 import type { AuthSession } from './auth.interface'
3 import { Logger } from '@nestjs/common'
4 import { AccountType, AppException, ChannelAuthSessionStatus, ResponseCode } from '@yikart/common'
5 import { AccountStatus } from '@yikart/mongodb'
6 import { beforeEach, describe, expect, it, vi } from 'vitest'
7 import { ChannelPlatformException, PlatformErrorCategory, PlatformErrorCauseType } from '../platforms/platforms.exception'
8 import { AuthCallbackResponseType } from '../platforms/platforms.interface'
9 import { ChannelAuthSessionFlow } from './auth.interface'
10 import { AuthService } from './auth.service'
11
12 vi.mock('@yikart/mongodb', async () => {
13 const { z } = await import('zod')
14
15 return {
16 mongodbConfigSchema: z.any(),
17 AssetStatus: {
18 Pending: 'pending',
19 Uploaded: 'uploaded',
20 Confirmed: 'confirmed',
21 Failed: 'failed',
22 },
23 AssetType: {
24 AiImage: 'aiImage',
25 AiVideo: 'aiVideo',
26 AiCard: 'aiCard',
27 AiChatImage: 'aiChatImage',
28 AideoOutput: 'aideoOutput',
29 VideoEdit: 'videoEdit',
30 DramaRecap: 'dramaRecap',
31 StyleTransfer: 'styleTransfer',
32 ImageEdit: 'imageEdit',
33 Subtitle: 'subtitle',
34 UserMedia: 'userMedia',
35 UserFile: 'userFile',
36 PublishMedia: 'publishMedia',
37 Avatar: 'avatar',
38 AgentSession: 'agentSession',
39 VideoThumbnail: 'videoThumbnail',
40 GooglePlace: 'googlePlace',
41 Temp: 'temp',
42 },
43 AccountStatus: {
44 NORMAL: 1,
45 ABNORMAL: 0,
46 },
47 Transactional: () => () => undefined,
48 AccountGroupRepository: class AccountGroupRepository {},
49 AccountRepository: class AccountRepository {},
50 AssetRepository: class AssetRepository {},
51 OAuth2CredentialRepository: class OAuth2CredentialRepository {},
52 }
53 })
54
55 vi.mock('@yikart/channel-db', async () => {
56 const { z } = await import('zod')
57
58 return {
59 mongodbConfigSchema: z.any(),
60 ChannelAuthIdentityRepository: class ChannelAuthIdentityRepository {},
61 }
62 })
63
64 vi.mock('@yikart/redis', async () => {
65 const { z } = await import('zod')
66
67 return {
68 redisConfigSchema: z.any(),
69 RedisService: class RedisService {},
70 EventStream: {
71 Channels: 'channels',
72 },
73 EventStreamService: class EventStreamService {},
74 EventTopic: {
75 ChannelsAccountConnected: 'channels.account.connected',
76 ChannelsAccountOffline: 'channels.account.offline',
77 },
78 }
79 })
80
81 vi.mock('@yikart/aitoearn-auth', async () => {
82 const { z } = await import('zod')
83
84 return {
85 aitoearnAuthConfigSchema: z.any(),
86 GetToken: () => () => undefined,
87 Public: () => () => undefined,
88 TokenInfo: class TokenInfo {},
89 }
90 })
91
92 vi.mock('../../user/user.service', () => ({
93 UserService: class UserService {},
94 }))
95
96 vi.mock('../relay/relay-client.service', () => ({
97 RelayClientService: class RelayClientService {},
98 }))
99
100 function createService() {
101 const provider = {
102 generateAuthUrl: vi.fn(async (input: GenerateAuthUrlInput) => ({
103 url: `https://provider.example.test/oauth?state=${input.state}`,
104 state: input.state,
105 redirectUri: 'https://api.example.test/v2/channels/accounts/auth/twitter/callback',
106 })),
107 exchangeCode: vi.fn(async () => ({
108 accessToken: 'access-token',
109 refreshToken: 'refresh-token',
110 expiresAt: new Date('2026-01-01T00:00:00.000Z'),
111 })),
112 refresh: vi.fn(),
113 revoke: vi.fn(async () => undefined),
114 getProfile: vi.fn(async () => ({
115 platformUid: 'platform-user',
116 displayName: 'Platform User',
117 })),
118 listSelectableAccounts: undefined as undefined | ((input: { accessToken: string, refreshToken?: string }) => Promise<unknown[]>),
119 }
120 const registry = {
121 has: vi.fn(() => true),
122 getAuth: vi.fn(() => provider),
123 get: vi.fn((platform: AccountType) => ({
124 auth: provider,
125 metadata: {
126 displayName: platform === AccountType.Facebook
127 ? {
128 'en-US': 'Facebook',
129 'zh-CN': 'Facebook',
130 }
131 : {
132 'en-US': 'Twitter / X',
133 'zh-CN': 'Twitter / X',
134 },
135 logoUrl: platform === AccountType.Facebook
136 ? 'https://cdn.example.test/facebook.svg'
137 : 'https://cdn.example.test/twitter.svg',
138 authInstructions: platform === AccountType.Facebook
139 ? undefined
140 : {
141 'en-US': 'Continue in the platform authorization window.',
142 'zh-CN': '请在平台授权窗口中继续操作。',
143 },
144 emptyAccountHint: platform === AccountType.Facebook
145 ? {
146 title: {
147 'en-US': 'No Facebook Page found',
148 'zh-CN': '未找到 Facebook 公共主页',
149 },
150 description: {
151 'en-US': 'Create a Facebook Page first, then authorize again.',
152 'zh-CN': '请先创建 Facebook 公共主页后重新授权。',
153 },
154 action: {
155 label: {
156 'en-US': 'Create Facebook Page',
157 'zh-CN': '创建 Facebook 公共主页',
158 },
159 url: 'https://www.facebook.com/pages/create',
160 },
161 }
162 : undefined,
163 },
164 })),
165 }
166 const credentialService = {
167 saveCredential: vi.fn(async () => undefined),
168 getCredential: vi.fn(),
169 lockRefresh: vi.fn(),
170 unlockRefresh: vi.fn(),
171 invalidateCredential: vi.fn(),
172 deleteCredential: vi.fn(async () => undefined),
173 deleteCredentialRecord: vi.fn(async () => undefined),
174 tryRefresh: vi.fn(),
175 }
176 const accountRepo = {
177 getByIdentity: vi.fn(async () => null),
178 createByIdentity: vi.fn(async () => ({
179 id: 'account-1',
180 userId: 'user-1',
181 })),
182 updateByIdentity: vi.fn(async () => ({
183 id: 'account-1',
184 userId: 'user-1',
185 })),
186 getByIdAndUserId: vi.fn(),
187 getAccountById: vi.fn(),
188 updateById: vi.fn(),
189 }
190 const accountGroupRepo = {
191 getById: vi.fn(async (id: string) => ({
192 id,
193 userId: 'user-1',
194 })),
195 getDefaultGroup: vi.fn(async () => ({
196 id: 'group-default',
197 })),
198 }
199 const redis = {
200 saveChannelAuthSession: vi.fn(async () => true),
201 getChannelAuthSession: vi.fn(),
202 }
203 const eventStream = {
204 emit: vi.fn(async () => undefined),
205 }
206 const authTokenService = {
207 generateToken: vi.fn(() => 'jwt-token'),
208 decodeToken: vi.fn(() => ({ exp: 1800000000 })),
209 }
210 const userService = {
211 getUserInfoById: vi.fn(async () => ({
212 id: 'user-1',
213 mail: 'user@example.test',
214 name: 'User',
215 })),
216 }
217 const identityRepo = {
218 deleteByPlatformAndUserId: vi.fn(async () => undefined),
219 deleteByPlatformAndSubjectUid: vi.fn(async () => undefined),
220 createOrUpdateByPlatformAndSubjectUid: vi.fn(async () => ({
221 id: 'identity-1',
222 })),
223 }
224
225 return {
226 service: new AuthService(
227 registry as never,
228 credentialService as never,
229 accountRepo as never,
230 accountGroupRepo as never,
231 redis as never,
232 eventStream as never,
233 ),
234 provider,
235 credentialService,
236 accountRepo,
237 accountGroupRepo,
238 redis,
239 eventStream,
240 authTokenService,
241 userService,
242 identityRepo,
243 }
244 }
245
246 describe('channel auth service', () => {
247 beforeEach(() => {
248 vi.clearAllMocks()
249 })
250
251 it('starts auth with backend-owned scopes and short random ids', async () => {
252 const { service, provider, redis } = createService()
253
254 const result = await service.startAuth({
255 userId: 'user-1',
256 platform: AccountType.Twitter,
257 callbackUrl: 'https://client.example.test/callback',
258 redirectUri: 'https://client.example.test/redirect',
259 })
260
261 expect(result.sessionId).toHaveLength(16)
262 expect(provider.generateAuthUrl).toHaveBeenCalledTimes(1)
263
264 const authInput = provider.generateAuthUrl.mock.calls[0][0]
265 expect(authInput).toMatchObject({
266 userId: 'user-1',
267 deviceType: 'unknown',
268 })
269 expect(authInput.state).toHaveLength(16)
270 expect(authInput.state).toBe(result.sessionId)
271 expect(authInput).not.toHaveProperty('scopes')
272 expect(authInput).not.toHaveProperty('callbackUrl')
273
274 const savedSession = redis.saveChannelAuthSession.mock.calls[0][1] as AuthSession
275 expect(redis.saveChannelAuthSession).toHaveBeenCalledWith(result.sessionId, savedSession)
276 expect(savedSession).toMatchObject({
277 flow: ChannelAuthSessionFlow.AccountAuth,
278 id: result.sessionId,
279 userId: 'user-1',
280 platform: AccountType.Twitter,
281 callbackUrl: 'https://client.example.test/callback',
282 redirectUri: 'https://client.example.test/redirect',
283 groupId: 'group-default',
284 status: ChannelAuthSessionStatus.Pending,
285 })
286 expect(savedSession.redirectUri).not.toBe('https://api.example.test/v2/channels/accounts/auth/twitter/callback')
287 expect(savedSession).not.toHaveProperty('state')
288 expect(savedSession).not.toHaveProperty('authInstructions')
289 expect(savedSession.createdAt).toBeInstanceOf(Date)
290 expect(savedSession.expiresAt).toBeInstanceOf(Date)
291 expect(result.expiresAt).toBe(savedSession.expiresAt)
292 expect(result.authInstructions).toEqual({
293 'en-US': 'Continue in the platform authorization window.',
294 'zh-CN': '请在平台授权窗口中继续操作。',
295 })
296 })
297
298 it('starts auth with a group owned by the current user', async () => {
299 const { service, accountGroupRepo, redis } = createService()
300
301 await service.startAuth({
302 userId: 'user-1',
303 platform: AccountType.Twitter,
304 groupId: 'group-1',
305 })
306
307 expect(accountGroupRepo.getById).toHaveBeenCalledWith('group-1')
308 const savedSession = redis.saveChannelAuthSession.mock.calls[0][1] as AuthSession
309 expect(savedSession.groupId).toBe('group-1')
310 })
311
312 it('rejects auth start when the requested group belongs to another user', async () => {
313 const { service, provider, accountGroupRepo, redis } = createService()
314 accountGroupRepo.getById.mockResolvedValueOnce({
315 id: 'group-other',
316 userId: 'user-other',
317 })
318
319 await expect(service.startAuth({
320 userId: 'user-1',
321 platform: AccountType.Twitter,
322 groupId: 'group-other',
323 })).rejects.toMatchObject({
324 code: ResponseCode.AccountGroupNotFound,
325 })
326
327 expect(provider.generateAuthUrl).not.toHaveBeenCalled()
328 expect(redis.saveChannelAuthSession).not.toHaveBeenCalled()
329 })
330
331 it('passes parsed desktop device type to auth providers', async () => {
332 const { service, provider } = createService()
333
334 await service.startAuth({
335 userId: 'user-1',
336 platform: AccountType.Kwai,
337 userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
338 })
339
340 expect(provider.generateAuthUrl).toHaveBeenCalledWith(expect.objectContaining({
341 userId: 'user-1',
342 deviceType: 'desktop',
343 }))
344 })
345
346 it('passes explicit device type to auth providers before parsing user agent', async () => {
347 const { service, provider } = createService()
348
349 await service.startAuth({
350 userId: 'user-1',
351 platform: AccountType.Kwai,
352 userAgent: '',
353 deviceType: 'desktop',
354 })
355
356 expect(provider.generateAuthUrl).toHaveBeenCalledWith(expect.objectContaining({
357 userId: 'user-1',
358 deviceType: 'desktop',
359 }))
360 })
361
362 it('lists existing channel account owners by OAuth profile and selectable account identities', async () => {
363 const { service, accountRepo } = createService()
364 accountRepo.getByIdentity.mockImplementation(async (identity: { type: AccountType, uid: string, account?: string }) => {
365 if (identity.type === AccountType.Facebook && identity.uid === 'profile-1') {
366 return { id: 'account-profile-1', userId: 'user-1' }
367 }
368 if (identity.type === AccountType.Facebook && identity.uid === 'page-1') {
369 return { id: 'account-page-1', userId: 'user-1' }
370 }
371 if (identity.type === AccountType.YouTube && identity.uid === 'google-user-1' && identity.account === 'channel-1') {
372 return { id: 'account-channel-1', userId: 'user-2' }
373 }
374 return null
375 })
376
377 const result = await service.listAccountOwnerIds({
378 platform: AccountType.Facebook,
379 profile: {
380 platformUid: 'profile-1',
381 displayName: 'Facebook User',
382 },
383 selectableAccounts: [
384 {
385 platform: AccountType.Facebook,
386 platformUid: 'page-1',
387 displayName: 'Page One',
388 },
389 {
390 platform: AccountType.YouTube,
391 platformUid: 'google-user-1',
392 account: 'channel-1',
393 displayName: 'Channel One',
394 },
395 ],
396 })
397
398 expect(accountRepo.getByIdentity).toHaveBeenCalledWith({ type: AccountType.Facebook, uid: 'profile-1', account: undefined })
399 expect(accountRepo.getByIdentity).toHaveBeenCalledWith({ type: AccountType.Facebook, uid: 'page-1', account: undefined })
400 expect(accountRepo.getByIdentity).toHaveBeenCalledWith({ type: AccountType.YouTube, uid: 'google-user-1', account: 'channel-1' })
401 expect(result).toEqual(['user-1', 'user-2'])
402 })
403
404 it('returns a pending auth session status for polling', async () => {
405 const { service, redis } = createService()
406 redis.getChannelAuthSession.mockResolvedValue({
407 id: 'session-1',
408 flow: ChannelAuthSessionFlow.AccountAuth,
409 userId: 'user-1',
410 platform: AccountType.Twitter,
411 groupId: 'group-default',
412 status: ChannelAuthSessionStatus.Pending,
413 createdAt: new Date('2026-01-01T00:00:00.000Z'),
414 } satisfies AuthSession)
415
416 const result = await service.getAuthSessionResult('user-1', AccountType.Twitter, 'session-1')
417
418 expect(result).toEqual({
419 sessionId: 'session-1',
420 status: ChannelAuthSessionStatus.Pending,
421 requiresSelection: false,
422 errorCode: undefined,
423 accountId: undefined,
424 accountIds: undefined,
425 accounts: undefined,
426 selectableAccounts: undefined,
427 })
428 expect(result).not.toHaveProperty('callbackUrl')
429 expect(result).not.toHaveProperty('redirectUri')
430 })
431
432 it('returns failed auth session status with error code for polling', async () => {
433 const { service, redis } = createService()
434 redis.getChannelAuthSession.mockResolvedValue({
435 id: 'session-1',
436 flow: ChannelAuthSessionFlow.AccountAuth,
437 userId: 'user-1',
438 platform: AccountType.Twitter,
439 groupId: 'group-default',
440 status: ChannelAuthSessionStatus.Failed,
441 errorCode: ResponseCode.ChannelAuthorizationFailed,
442 createdAt: new Date('2026-01-01T00:00:00.000Z'),
443 } satisfies AuthSession)
444
445 await expect(service.getAuthSessionResult('user-1', AccountType.Twitter, 'session-1'))
446 .resolves
447 .toMatchObject({
448 sessionId: 'session-1',
449 status: ChannelAuthSessionStatus.Failed,
450 requiresSelection: false,
451 errorCode: ResponseCode.ChannelAuthorizationFailed,
452 })
453 })
454
455 it('marks pending account auth sessions as failed and clears selectable snapshots', async () => {
456 const { service, redis } = createService()
457 redis.getChannelAuthSession.mockResolvedValue({
458 id: 'session-1',
459 flow: ChannelAuthSessionFlow.AccountAuth,
460 userId: 'user-1',
461 platform: AccountType.Facebook,
462 groupId: 'group-default',
463 status: ChannelAuthSessionStatus.Pending,
464 selectableAccounts: [{
465 platform: AccountType.Facebook,
466 platformUid: 'page-1',
467 displayName: 'Page One',
468 credential: {
469 accessToken: 'page-access-token',
470 },
471 }],
472 rootCredentialId: 'root-credential-1',
473 createdAt: new Date('2026-01-01T00:00:00.000Z'),
474 } satisfies AuthSession)
475
476 await service.markSessionFailed('session-1', ResponseCode.ChannelAuthorizationFailed)
477
478 const savedSession = redis.saveChannelAuthSession.mock.calls[0][1] as AuthSession
479 expect(savedSession.status).toBe(ChannelAuthSessionStatus.Failed)
480 expect(savedSession.errorCode).toBe(ResponseCode.ChannelAuthorizationFailed)
481 expect(savedSession.selectableAccounts).toBeUndefined()
482 expect(savedSession.rootCredentialId).toBeUndefined()
483 })
484
485 it('uses semantic redis access and enum status when completing callback', async () => {
486 const { service, provider, credentialService, accountRepo, redis, eventStream } = createService()
487 provider.getProfile.mockResolvedValueOnce({
488 platformUid: 'platform-user',
489 displayName: 'Platform User',
490 fansCount: 120,
491 followingCount: 15,
492 })
493 const session: AuthSession = {
494 id: 'session-1',
495 flow: ChannelAuthSessionFlow.AccountAuth,
496 userId: 'user-1',
497 platform: AccountType.Twitter,
498 groupId: 'group-default',
499 redirectUri: 'https://client.example.test/redirect',
500 status: ChannelAuthSessionStatus.Pending,
501 createdAt: new Date('2026-01-01T00:00:00.000Z'),
502 }
503 redis.getChannelAuthSession.mockResolvedValue(session)
504
505 const result = await service.completeCallback(
506 AccountType.Twitter,
507 { query: { code: 'code-1', state: 'session-1' } },
508 'session-1',
509 )
510
511 expect(redis.getChannelAuthSession).toHaveBeenCalledWith('session-1')
512 expect(provider.exchangeCode).toHaveBeenCalledWith({
513 query: {
514 code: 'code-1',
515 state: 'session-1',
516 },
517 session,
518 })
519 expect(accountRepo.createByIdentity).toHaveBeenCalledWith(
520 { type: AccountType.Twitter, uid: 'platform-user' },
521 expect.objectContaining({
522 userId: 'user-1',
523 type: AccountType.Twitter,
524 fansCount: 120,
525 followingCount: 15,
526 }),
527 )
528 expect(credentialService.saveCredential).toHaveBeenCalledWith(
529 'account-1',
530 AccountType.Twitter,
531 expect.objectContaining({
532 accessToken: 'access-token',
533 refreshToken: 'refresh-token',
534 }),
535 )
536 expect(redis.saveChannelAuthSession).toHaveBeenCalledWith(
537 'session-1',
538 expect.objectContaining({
539 status: ChannelAuthSessionStatus.Completed,
540 }),
541 )
542 expect(eventStream.emit).toHaveBeenCalled()
543 expect(result.accountId).toBe('account-1')
544 expect(result).toMatchObject({
545 platformDisplayName: 'Twitter / X',
546 platformLogoUrl: 'https://cdn.example.test/twitter.svg',
547 })
548
549 const savedSession = redis.saveChannelAuthSession.mock.calls[0][1]
550 expect(savedSession).toMatchObject({
551 accountId: 'account-1',
552 accountIds: ['account-1'],
553 accounts: [{
554 accountId: 'account-1',
555 platform: AccountType.Twitter,
556 platformUid: 'platform-user',
557 displayName: 'Platform User',
558 }],
559 })
560 })
561
562 it('stores mutable account handles without using them in the local account identity key', async () => {
563 const { service, provider, accountRepo, redis } = createService()
564 provider.getProfile.mockResolvedValueOnce({
565 platformUid: 'platform-user',
566 account: 'mutable_handle',
567 displayName: 'Platform User',
568 })
569 redis.getChannelAuthSession.mockResolvedValue({
570 id: 'session-1',
571 flow: ChannelAuthSessionFlow.AccountAuth,
572 userId: 'user-1',
573 platform: AccountType.Twitter,
574 groupId: 'group-default',
575 status: ChannelAuthSessionStatus.Pending,
576 createdAt: new Date('2026-01-01T00:00:00.000Z'),
577 } satisfies AuthSession)
578
579 await service.completeCallback(
580 AccountType.Twitter,
581 { query: { code: 'code-1', state: 'session-1' } },
582 'session-1',
583 )
584
585 expect(accountRepo.createByIdentity).toHaveBeenCalledWith(
586 { type: AccountType.Twitter, uid: 'platform-user', account: undefined },
587 expect.objectContaining({
588 type: AccountType.Twitter,
589 uid: 'platform-user',
590 account: 'mutable_handle',
591 nickname: 'Platform User',
592 }),
593 )
594 })
595
596 it('migrates a channel account owner when account auth completes', async () => {
597 const { service, credentialService, accountRepo, redis } = createService()
598 const session: AuthSession = {
599 id: 'session-1',
600 flow: ChannelAuthSessionFlow.AccountAuth,
601 userId: 'user-1',
602 platform: AccountType.Twitter,
603 groupId: 'group-default',
604 status: ChannelAuthSessionStatus.Pending,
605 createdAt: new Date('2026-01-01T00:00:00.000Z'),
606 }
607 redis.getChannelAuthSession.mockResolvedValue(session)
608 accountRepo.getByIdentity.mockResolvedValueOnce({
609 id: 'account-1',
610 userId: 'user-2',
611 type: AccountType.Twitter,
612 uid: 'platform-user',
613 })
614 accountRepo.updateByIdentity.mockResolvedValueOnce({
615 id: 'account-1',
616 userId: 'user-1',
617 type: AccountType.Twitter,
618 uid: 'platform-user',
619 })
620
621 const result = await service.completeCallback(
622 AccountType.Twitter,
623 { query: { code: 'code-1', state: 'session-1' } },
624 'session-1',
625 )
626
627 expect(accountRepo.updateByIdentity).toHaveBeenCalledWith(
628 { type: AccountType.Twitter, uid: 'platform-user' },
629 expect.objectContaining({
630 userId: 'user-1',
631 type: AccountType.Twitter,
632 uid: 'platform-user',
633 }),
634 )
635 expect(credentialService.saveCredential).toHaveBeenCalledWith(
636 'account-1',
637 AccountType.Twitter,
638 expect.objectContaining({
639 accessToken: 'access-token',
640 refreshToken: 'refresh-token',
641 }),
642 )
643 expect(result.accountId).toBe('account-1')
644 })
645
646 it('restores abnormal channel account status when account auth completes again', async () => {
647 const { service, credentialService, accountRepo, redis } = createService()
648 redis.getChannelAuthSession.mockResolvedValue({
649 id: 'session-1',
650 flow: ChannelAuthSessionFlow.AccountAuth,
651 userId: 'user-1',
652 platform: AccountType.Twitter,
653 groupId: 'group-default',
654 status: ChannelAuthSessionStatus.Pending,
655 createdAt: new Date('2026-01-01T00:00:00.000Z'),
656 } satisfies AuthSession)
657 accountRepo.getByIdentity.mockResolvedValueOnce({
658 id: 'account-1',
659 userId: 'user-1',
660 type: AccountType.Twitter,
661 uid: 'platform-user',
662 status: AccountStatus.ABNORMAL,
663 })
664 accountRepo.updateByIdentity.mockResolvedValueOnce({
665 id: 'account-1',
666 userId: 'user-1',
667 type: AccountType.Twitter,
668 uid: 'platform-user',
669 status: AccountStatus.NORMAL,
670 })
671
672 const result = await service.completeCallback(
673 AccountType.Twitter,
674 { query: { code: 'code-1', state: 'session-1' } },
675 'session-1',
676 )
677
678 expect(accountRepo.updateByIdentity).toHaveBeenCalledWith(
679 { type: AccountType.Twitter, uid: 'platform-user' },
680 expect.objectContaining({
681 status: AccountStatus.NORMAL,
682 }),
683 )
684 expect(credentialService.saveCredential).toHaveBeenCalledWith(
685 'account-1',
686 AccountType.Twitter,
687 expect.objectContaining({
688 accessToken: 'access-token',
689 refreshToken: 'refresh-token',
690 }),
691 )
692 expect(result.accountId).toBe('account-1')
693 })
694
695 it('rejects account auth if the repository still returns a different owner after migration', async () => {
696 const loggerWarn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined)
697 const { service, credentialService, accountRepo, redis } = createService()
698 redis.getChannelAuthSession.mockResolvedValue({
699 id: 'session-1',
700 flow: ChannelAuthSessionFlow.AccountAuth,
701 userId: 'user-1',
702 platform: AccountType.Twitter,
703 groupId: 'group-default',
704 status: ChannelAuthSessionStatus.Pending,
705 createdAt: new Date('2026-01-01T00:00:00.000Z'),
706 } satisfies AuthSession)
707 accountRepo.getByIdentity.mockResolvedValueOnce({
708 id: 'account-owned-by-user-2',
709 userId: 'user-2',
710 type: AccountType.Twitter,
711 uid: 'platform-user',
712 })
713 accountRepo.updateByIdentity.mockResolvedValueOnce({
714 id: 'account-owned-by-user-2',
715 userId: 'user-2',
716 type: AccountType.Twitter,
717 uid: 'platform-user',
718 })
719
720 await expect(service.completeCallback(
721 AccountType.Twitter,
722 { query: { code: 'code-1', state: 'session-1' } },
723 'session-1',
724 )).rejects.toMatchObject({
725 code: ResponseCode.ChannelAccountAlreadyConnectedToAnotherUser,
726 })
727 expect(accountRepo.updateByIdentity).toHaveBeenCalledWith(
728 { type: AccountType.Twitter, uid: 'platform-user' },
729 expect.any(Object),
730 )
731 expect(credentialService.saveCredential).not.toHaveBeenCalled()
732 loggerWarn.mockRestore()
733 })
734
735 it('does not sign a user token or write login identity for account auth JSON callback output', async () => {
736 const { service, provider, redis, authTokenService, userService, identityRepo } = createService()
737 provider.exchangeCode.mockResolvedValueOnce({
738 accessToken: 'miniapp-access-token',
739 refreshToken: 'miniapp-refresh-token',
740 expiresAt: new Date('2026-01-01T00:00:00.000Z'),
741 callbackResponseType: AuthCallbackResponseType.Json,
742 profile: {
743 platformUid: 'douyin-openid',
744 displayName: 'Douyin User',
745 },
746 })
747 const session: AuthSession = {
748 id: 'session-1',
749 flow: ChannelAuthSessionFlow.AccountAuth,
750 userId: 'user-1',
751 platform: AccountType.Douyin,
752 groupId: 'group-default',
753 status: ChannelAuthSessionStatus.Pending,
754 expiresAt: new Date(Date.now() + 5 * 60 * 1000),
755 createdAt: new Date('2026-01-01T00:00:00.000Z'),
756 }
757 redis.getChannelAuthSession.mockResolvedValue(session)
758
759 const result = await service.completeCallback(
760 AccountType.Douyin,
761 {
762 body: {
763 state: 'session-1',
764 token: 'login-token',
765 tickets: {
766 'ma.user.data': 'user-data-ticket',
767 'ma.video.bind': 'video-bind-ticket',
768 },
769 },
770 },
771 'session-1',
772 )
773
774 expect(identityRepo.deleteByPlatformAndUserId).not.toHaveBeenCalled()
775 expect(identityRepo.deleteByPlatformAndSubjectUid).not.toHaveBeenCalled()
776 expect(identityRepo.createOrUpdateByPlatformAndSubjectUid).not.toHaveBeenCalled()
777 expect(userService.getUserInfoById).not.toHaveBeenCalled()
778 expect(authTokenService.generateToken).not.toHaveBeenCalled()
779 expect(result).toMatchObject({
780 accountId: 'account-1',
781 callbackResponseType: AuthCallbackResponseType.Json,
782 })
783 expect(result).not.toHaveProperty('token')
784 expect(result).not.toHaveProperty('exp')
785 expect(provider.getProfile).not.toHaveBeenCalled()
786 })
787
788 it('passes callback state validation responsibility to provider', async () => {
789 const { service, provider, redis } = createService()
790 const session: AuthSession = {
791 id: 'session-1',
792 flow: ChannelAuthSessionFlow.AccountAuth,
793 userId: 'user-1',
794 platform: AccountType.Twitter,
795 groupId: 'group-default',
796 status: ChannelAuthSessionStatus.Pending,
797 createdAt: new Date('2026-01-01T00:00:00.000Z'),
798 }
799 redis.getChannelAuthSession.mockResolvedValue(session)
800
801 await service.completeCallback(
802 AccountType.Twitter,
803 { query: { code: 'code-1', state: 'other-session' } },
804 'session-1',
805 )
806
807 expect(provider.exchangeCode).toHaveBeenCalledWith({
808 query: { code: 'code-1', state: 'other-session' },
809 session,
810 })
811 })
812
813 it('returns a completed auth session status after callback', async () => {
814 const { service, redis } = createService()
815 redis.getChannelAuthSession.mockResolvedValue({
816 id: 'session-1',
817 flow: ChannelAuthSessionFlow.AccountAuth,
818 userId: 'user-1',
819 platform: AccountType.Twitter,
820 groupId: 'group-default',
821 status: ChannelAuthSessionStatus.Completed,
822 accountId: 'account-1',
823 accountIds: ['account-1'],
824 accounts: [{
825 accountId: 'account-1',
826 platform: AccountType.Twitter,
827 platformUid: 'platform-user',
828 displayName: 'Platform User',
829 }],
830 callbackUrl: 'https://client.example.test/callback',
831 redirectUri: 'https://client.example.test/redirect',
832 createdAt: new Date('2026-01-01T00:00:00.000Z'),
833 } satisfies AuthSession)
834
835 const result = await service.getAuthSessionResult('user-1', AccountType.Twitter, 'session-1')
836
837 expect(result).toMatchObject({
838 sessionId: 'session-1',
839 status: ChannelAuthSessionStatus.Completed,
840 requiresSelection: false,
841 accountId: 'account-1',
842 accountIds: ['account-1'],
843 })
844 expect(result).not.toHaveProperty('callbackUrl')
845 expect(result).not.toHaveProperty('redirectUri')
846 })
847
848 it('keeps selectable accounts pending until user selection is submitted', async () => {
849 const { service, provider, redis } = createService()
850 provider.listSelectableAccounts = vi.fn(async () => [
851 {
852 platform: AccountType.Facebook,
853 platformUid: 'page-1',
854 displayName: 'Page One',
855 parentPlatformUid: 'profile-1',
856 credential: {
857 accessToken: 'page-access-token',
858 refreshToken: 'page-refresh-token',
859 },
860 },
861 {
862 platform: AccountType.Facebook,
863 platformUid: 'page-2',
864 displayName: 'Page Two',
865 parentPlatformUid: 'profile-1',
866 credential: {
867 accessToken: 'page-access-token-2',
868 refreshToken: 'page-refresh-token',
869 },
870 },
871 ])
872 const session: AuthSession = {
873 id: 'session-1',
874 flow: ChannelAuthSessionFlow.AccountAuth,
875 userId: 'user-1',
876 platform: AccountType.Facebook,
877 groupId: 'group-default',
878 status: ChannelAuthSessionStatus.Pending,
879 createdAt: new Date('2026-01-01T00:00:00.000Z'),
880 }
881 redis.getChannelAuthSession.mockResolvedValue(session)
882
883 const callbackResult = await service.completeCallback(
884 AccountType.Facebook,
885 { query: { code: 'code-1', state: 'session-1' } },
886 'session-1',
887 )
888
889 expect(callbackResult).toMatchObject({
890 requiresSelection: true,
891 platformDisplayName: 'Facebook',
892 platformLogoUrl: 'https://cdn.example.test/facebook.svg',
893 accounts: [{
894 platform: AccountType.Facebook,
895 platformUid: 'page-1',
896 displayName: 'Page One',
897 parentPlatformUid: 'profile-1',
898 }, {
899 platform: AccountType.Facebook,
900 platformUid: 'page-2',
901 displayName: 'Page Two',
902 parentPlatformUid: 'profile-1',
903 }],
904 })
905
906 const savedPendingSession = redis.saveChannelAuthSession.mock.calls[0][1] as AuthSession
907 expect(savedPendingSession.status).toBe(ChannelAuthSessionStatus.Pending)
908 expect(savedPendingSession.selectableAccounts).toHaveLength(2)
909
910 redis.getChannelAuthSession.mockResolvedValue(savedPendingSession)
911 await expect(service.getAuthSessionResult('user-1', AccountType.Facebook, 'session-1'))
912 .resolves
913 .toMatchObject({
914 sessionId: 'session-1',
915 status: ChannelAuthSessionStatus.Pending,
916 requiresSelection: true,
917 selectableAccounts: [{
918 platform: AccountType.Facebook,
919 platformUid: 'page-1',
920 displayName: 'Page One',
921 parentPlatformUid: 'profile-1',
922 }, {
923 platform: AccountType.Facebook,
924 platformUid: 'page-2',
925 displayName: 'Page Two',
926 parentPlatformUid: 'profile-1',
927 }],
928 })
929 })
930
931 it('keeps empty selectable accounts pending with the platform empty account hint', async () => {
932 const { service, provider, credentialService, accountRepo, redis } = createService()
933 provider.listSelectableAccounts = vi.fn(async () => [])
934 const session: AuthSession = {
935 id: 'session-1',
936 flow: ChannelAuthSessionFlow.AccountAuth,
937 userId: 'user-1',
938 platform: AccountType.Facebook,
939 groupId: 'group-default',
940 status: ChannelAuthSessionStatus.Pending,
941 createdAt: new Date('2026-01-01T00:00:00.000Z'),
942 }
943 redis.getChannelAuthSession.mockResolvedValue(session)
944
945 const callbackResult = await service.completeCallback(
946 AccountType.Facebook,
947 { query: { code: 'code-1', state: 'session-1' } },
948 'session-1',
949 )
950
951 expect(callbackResult).toMatchObject({
952 requiresSelection: true,
953 accounts: [],
954 emptyAccountHint: {
955 title: 'No Facebook Page found',
956 description: 'Create a Facebook Page first, then authorize again.',
957 action: {
958 label: 'Create Facebook Page',
959 url: 'https://www.facebook.com/pages/create',
960 },
961 },
962 })
963 expect(accountRepo.createByIdentity).not.toHaveBeenCalled()
964 expect(accountRepo.updateByIdentity).not.toHaveBeenCalled()
965 expect(credentialService.saveCredential).not.toHaveBeenCalled()
966
967 const savedPendingSession = redis.saveChannelAuthSession.mock.calls[0][1] as AuthSession
968 expect(savedPendingSession).toMatchObject({
969 status: ChannelAuthSessionStatus.Pending,
970 selectableAccounts: [],
971 })
972 })
973
974 it('connects the only selectable account without rendering the selection step', async () => {
975 const { service, provider, credentialService, accountRepo, redis } = createService()
976 provider.listSelectableAccounts = vi.fn(async () => [{
977 platform: AccountType.Facebook,
978 platformUid: 'page-1',
979 displayName: 'Page One',
980 parentPlatformUid: 'profile-1',
981 avatarUrl: 'https://cdn.example.test/page-1.png',
982 fansCount: 300,
983 followingCount: 9,
984 credential: {
985 accessToken: 'page-access-token',
986 refreshToken: 'page-refresh-token',
987 },
988 }])
989 const session: AuthSession = {
990 id: 'session-1',
991 flow: ChannelAuthSessionFlow.AccountAuth,
992 userId: 'user-1',
993 platform: AccountType.Facebook,
994 groupId: 'group-default',
995 callbackUrl: 'https://client.example.test/callback',
996 redirectUri: 'https://client.example.test/redirect',
997 status: ChannelAuthSessionStatus.Pending,
998 createdAt: new Date('2026-01-01T00:00:00.000Z'),
999 }
1000 redis.getChannelAuthSession.mockResolvedValue(session)
1001 accountRepo.createByIdentity.mockResolvedValue({ id: 'account-page-1', userId: 'user-1' })
1002
1003 const callbackResult = await service.completeCallback(
1004 AccountType.Facebook,
1005 { query: { code: 'code-1', state: 'session-1' } },
1006 'session-1',
1007 )
1008
1009 expect(callbackResult.requiresSelection).toBeUndefined()
1010 expect(accountRepo.createByIdentity).toHaveBeenCalledWith(
1011 { type: AccountType.Facebook, uid: 'page-1' },
1012 expect.objectContaining({
1013 fansCount: 300,
1014 followingCount: 9,
1015 }),
1016 )
1017 expect(callbackResult).toMatchObject({
1018 accountId: 'account-page-1',
1019 connectedAccounts: [{
1020 accountId: 'account-page-1',
1021 platform: AccountType.Facebook,
1022 platformUid: 'page-1',
1023 displayName: 'Page One',
1024 avatarUrl: 'https://cdn.example.test/page-1.png',
1025 }],
1026 platformDisplayName: 'Facebook',
1027 platformLogoUrl: 'https://cdn.example.test/facebook.svg',
1028 callbackUrl: 'https://client.example.test/callback',
1029 redirectUri: 'https://client.example.test/redirect',
1030 })
1031 expect(credentialService.saveCredential).toHaveBeenCalledWith(
1032 'account-page-1',
1033 AccountType.Facebook,
1034 {
1035 accessToken: 'page-access-token',
1036 refreshToken: 'page-refresh-token',
1037 expiresAt: undefined,
1038 },
1039 )
1040
1041 const savedCompletedSession = redis.saveChannelAuthSession.mock.calls[0][1] as AuthSession
1042 expect(savedCompletedSession).toMatchObject({
1043 status: ChannelAuthSessionStatus.Completed,
1044 accountId: 'account-page-1',
1045 accountIds: ['account-page-1'],
1046 accounts: [{
1047 accountId: 'account-page-1',
1048 platform: AccountType.Facebook,
1049 platformUid: 'page-1',
1050 displayName: 'Page One',
1051 avatarUrl: 'https://cdn.example.test/page-1.png',
1052 }],
1053 })
1054 expect(savedCompletedSession.selectableAccounts).toBeUndefined()
1055 })
1056
1057 it('stores selected accounts and returns them from polling', async () => {
1058 const { service, credentialService, accountRepo, redis } = createService()
1059 const session: AuthSession = {
1060 id: 'session-1',
1061 flow: ChannelAuthSessionFlow.AccountAuth,
1062 userId: 'user-1',
1063 platform: AccountType.YouTube,
1064 groupId: 'group-default',
1065 callbackUrl: 'https://client.example.test/callback',
1066 redirectUri: 'https://client.example.test/redirect',
1067 status: ChannelAuthSessionStatus.Pending,
1068 selectableAccounts: [{
1069 platform: AccountType.YouTube,
1070 platformUid: 'google-user-1',
1071 account: 'channel-1',
1072 displayName: 'Channel One',
1073 credential: {
1074 accessToken: 'channel-access-token',
1075 refreshToken: 'channel-refresh-token',
1076 },
1077 }],
1078 createdAt: new Date('2026-01-01T00:00:00.000Z'),
1079 }
1080 redis.getChannelAuthSession.mockResolvedValue(session)
1081 accountRepo.createByIdentity.mockResolvedValue({ id: 'account-channel-1', userId: 'user-1' })
1082
1083 const result = await service.connectSelectableAccounts('session-1', [{
1084 platformUid: 'google-user-1',
1085 account: 'channel-1',
1086 }])
1087
1088 expect(credentialService.saveCredential).toHaveBeenCalledWith(
1089 'account-channel-1',
1090 AccountType.YouTube,
1091 {
1092 accessToken: 'channel-access-token',
1093 refreshToken: 'channel-refresh-token',
1094 expiresAt: undefined,
1095 },
1096 )
1097 expect(accountRepo.createByIdentity).toHaveBeenCalledWith(
1098 { type: AccountType.YouTube, uid: 'google-user-1', account: 'channel-1' },
1099 expect.objectContaining({
1100 type: AccountType.YouTube,
1101 uid: 'google-user-1',
1102 account: 'channel-1',
1103 nickname: 'Channel One',
1104 }),
1105 )
1106
1107 const savedCompletedSession = redis.saveChannelAuthSession.mock.calls[0][1] as AuthSession
1108 expect(savedCompletedSession).toMatchObject({
1109 status: ChannelAuthSessionStatus.Completed,
1110 accountId: 'account-channel-1',
1111 accountIds: ['account-channel-1'],
1112 accounts: [{
1113 accountId: 'account-channel-1',
1114 platform: AccountType.YouTube,
1115 platformUid: 'google-user-1',
1116 account: 'channel-1',
1117 displayName: 'Channel One',
1118 }],
1119 })
1120 expect(savedCompletedSession.selectableAccounts).toBeUndefined()
1121 expect(result).toMatchObject({
1122 accountIds: ['account-channel-1'],
1123 callbackUrl: 'https://client.example.test/callback',
1124 redirectUri: 'https://client.example.test/redirect',
1125 })
1126
1127 redis.getChannelAuthSession.mockResolvedValue(savedCompletedSession)
1128 await expect(service.getAuthSessionResult('user-1', AccountType.YouTube, 'session-1'))
1129 .resolves
1130 .toMatchObject({
1131 sessionId: 'session-1',
1132 status: ChannelAuthSessionStatus.Completed,
1133 requiresSelection: false,
1134 accountId: 'account-channel-1',
1135 accountIds: ['account-channel-1'],
1136 })
1137 })
1138
1139 it('updates existing YouTube channel accounts found by persisted identity fields', async () => {
1140 const { service, credentialService, accountRepo, redis } = createService()
1141 const session: AuthSession = {
1142 id: 'session-1',
1143 flow: ChannelAuthSessionFlow.AccountAuth,
1144 userId: 'user-1',
1145 platform: AccountType.YouTube,
1146 groupId: 'group-default',
1147 status: ChannelAuthSessionStatus.Pending,
1148 selectableAccounts: [{
1149 platform: AccountType.YouTube,
1150 platformUid: 'google-user-1',
1151 account: 'channel-1',
1152 displayName: 'Channel One',
1153 credential: {
1154 accessToken: 'channel-access-token',
1155 refreshToken: 'channel-refresh-token',
1156 },
1157 }],
1158 createdAt: new Date('2026-01-01T00:00:00.000Z'),
1159 }
1160 redis.getChannelAuthSession.mockResolvedValue(session)
1161 accountRepo.getByIdentity.mockResolvedValueOnce({
1162 id: 'youtube_google-user-1',
1163 userId: 'user-1',
1164 })
1165 accountRepo.updateByIdentity.mockResolvedValueOnce({
1166 id: 'youtube_google-user-1',
1167 userId: 'user-1',
1168 })
1169
1170 await service.connectSelectableAccounts('session-1', [{
1171 platformUid: 'google-user-1',
1172 account: 'channel-1',
1173 }])
1174
1175 expect(accountRepo.createByIdentity).not.toHaveBeenCalled()
1176 expect(accountRepo.updateByIdentity).toHaveBeenCalledWith(
1177 { type: AccountType.YouTube, uid: 'google-user-1', account: 'channel-1' },
1178 expect.objectContaining({
1179 type: AccountType.YouTube,
1180 uid: 'google-user-1',
1181 account: 'channel-1',
1182 nickname: 'Channel One',
1183 }),
1184 )
1185 expect(credentialService.saveCredential).toHaveBeenCalledWith(
1186 'youtube_google-user-1',
1187 AccountType.YouTube,
1188 {
1189 accessToken: 'channel-access-token',
1190 refreshToken: 'channel-refresh-token',
1191 expiresAt: undefined,
1192 },
1193 )
1194 })
1195
1196 it('stores selected accounts when the platform credential omits refresh token', async () => {
1197 const { service, credentialService, accountRepo, redis } = createService()
1198 const session: AuthSession = {
1199 id: 'session-1',
1200 flow: ChannelAuthSessionFlow.AccountAuth,
1201 userId: 'user-1',
1202 platform: AccountType.Facebook,
1203 groupId: 'group-default',
1204 status: ChannelAuthSessionStatus.Pending,
1205 selectableAccounts: [{
1206 platform: AccountType.Facebook,
1207 platformUid: 'page-1',
1208 displayName: 'Page One',
1209 credential: {
1210 accessToken: 'page-access-token',
1211 },
1212 }],
1213 createdAt: new Date('2026-01-01T00:00:00.000Z'),
1214 }
1215 redis.getChannelAuthSession.mockResolvedValue(session)
1216 accountRepo.createByIdentity.mockResolvedValue({ id: 'account-page-1', userId: 'user-1' })
1217
1218 await service.connectSelectableAccounts('session-1', [{ platformUid: 'page-1' }])
1219
1220 expect(credentialService.saveCredential).toHaveBeenCalledWith(
1221 'account-page-1',
1222 AccountType.Facebook,
1223 {
1224 accessToken: 'page-access-token',
1225 refreshToken: undefined,
1226 expiresAt: undefined,
1227 },
1228 )
1229 const savedCompletedSession = redis.saveChannelAuthSession.mock.calls[0][1] as AuthSession
1230 expect(savedCompletedSession.status).toBe(ChannelAuthSessionStatus.Completed)
1231 expect(savedCompletedSession.accountIds).toEqual(['account-page-1'])
1232 })
1233
1234 it('rejects empty selectable account submissions with a stable response code', async () => {
1235 const { service, accountRepo, redis } = createService()
1236 const session: AuthSession = {
1237 id: 'session-1',
1238 flow: ChannelAuthSessionFlow.AccountAuth,
1239 userId: 'user-1',
1240 platform: AccountType.Facebook,
1241 groupId: 'group-default',
1242 status: ChannelAuthSessionStatus.Pending,
1243 selectableAccounts: [{
1244 platform: AccountType.Facebook,
1245 platformUid: 'page-1',
1246 displayName: 'Page One',
1247 }],
1248 createdAt: new Date('2026-01-01T00:00:00.000Z'),
1249 }
1250 redis.getChannelAuthSession.mockResolvedValue(session)
1251
1252 await expect(service.connectSelectableAccounts('session-1', []))
1253 .rejects
1254 .toMatchObject({ code: ResponseCode.ChannelAuthSelectionRequired })
1255 expect(accountRepo.createByIdentity).not.toHaveBeenCalled()
1256 expect(accountRepo.updateByIdentity).not.toHaveBeenCalled()
1257 })
1258
1259 it('rejects selectable account ids outside the auth session with a stable response code', async () => {
1260 const { service, accountRepo, redis } = createService()
1261 const session: AuthSession = {
1262 id: 'session-1',
1263 flow: ChannelAuthSessionFlow.AccountAuth,
1264 userId: 'user-1',
1265 platform: AccountType.Facebook,
1266 groupId: 'group-default',
1267 status: ChannelAuthSessionStatus.Pending,
1268 selectableAccounts: [{
1269 platform: AccountType.Facebook,
1270 platformUid: 'page-1',
1271 displayName: 'Page One',
1272 }],
1273 createdAt: new Date('2026-01-01T00:00:00.000Z'),
1274 }
1275 redis.getChannelAuthSession.mockResolvedValue(session)
1276
1277 await expect(service.connectSelectableAccounts('session-1', [{ platformUid: 'page-2' }]))
1278 .rejects
1279 .toMatchObject({ code: ResponseCode.ChannelAuthSelectedAccountUnavailable })
1280 expect(accountRepo.createByIdentity).not.toHaveBeenCalled()
1281 expect(accountRepo.updateByIdentity).not.toHaveBeenCalled()
1282 })
1283
1284 it('rejects polling when session owner or platform does not match', async () => {
1285 const { service, redis } = createService()
1286 redis.getChannelAuthSession.mockResolvedValue({
1287 id: 'session-1',
1288 flow: ChannelAuthSessionFlow.AccountAuth,
1289 userId: 'user-1',
1290 platform: AccountType.Twitter,
1291 groupId: 'group-default',
1292 status: ChannelAuthSessionStatus.Pending,
1293 createdAt: new Date('2026-01-01T00:00:00.000Z'),
1294 } satisfies AuthSession)
1295
1296 await expect(service.getAuthSessionResult('user-2', AccountType.Twitter, 'session-1'))
1297 .rejects
1298 .toMatchObject({ code: ResponseCode.ChannelAuthSessionInvalid })
1299 await expect(service.getAuthSessionResult('user-1', AccountType.Facebook, 'session-1'))
1300 .rejects
1301 .toMatchObject({ code: ResponseCode.ChannelAuthPlatformMismatch })
1302 })
1303
1304 it('returns account auth status only for the owning user and platform', async () => {
1305 const { service, accountRepo } = createService()
1306 accountRepo.getByIdAndUserId.mockResolvedValue({
1307 id: 'account-1',
1308 type: AccountType.Twitter,
1309 uid: 'platform-user-1',
1310 status: AccountStatus.NORMAL,
1311 })
1312
1313 await expect(service.getAccountAuthStatus('user-1', AccountType.Twitter, 'account-1'))
1314 .resolves
1315 .toEqual({ status: AccountStatus.NORMAL })
1316
1317 await expect(service.getAccountAuthStatus('user-1', AccountType.Facebook, 'account-1'))
1318 .rejects
1319 .toMatchObject({ code: ResponseCode.ChannelAuthPlatformMismatch })
1320 })
1321
1322 it('revokes provider credential before marking account offline', async () => {
1323 const { service, provider, credentialService, accountRepo } = createService()
1324 accountRepo.getByIdAndUserId.mockResolvedValue({
1325 id: 'account-1',
1326 type: AccountType.Twitter,
1327 uid: 'platform-user-1',
1328 status: AccountStatus.NORMAL,
1329 })
1330 accountRepo.getAccountById.mockResolvedValue({
1331 id: 'account-1',
1332 type: AccountType.Twitter,
1333 })
1334 credentialService.getCredential.mockResolvedValue({
1335 accessToken: 'access-token',
1336 refreshToken: 'refresh-token',
1337 })
1338
1339 await service.revokeCredential('account-1', 'user-1')
1340
1341 expect(provider.revoke).toHaveBeenCalledWith({
1342 accessToken: 'access-token',
1343 refreshToken: 'refresh-token',
1344 platformUid: 'platform-user-1',
1345 })
1346 expect(accountRepo.updateById).toHaveBeenCalledWith('account-1', { status: AccountStatus.ABNORMAL })
1347 expect(credentialService.invalidateCredential).toHaveBeenCalledWith('account-1')
1348 expect(credentialService.deleteCredentialRecord).toHaveBeenCalledWith('account-1')
1349 expect(credentialService.deleteCredential).not.toHaveBeenCalled()
1350 })
1351
1352 it('rejects credential reads for abnormal accounts', async () => {
1353 const { service, credentialService, accountRepo } = createService()
1354 accountRepo.getByIdAndUserId.mockResolvedValue({
1355 id: 'account-1',
1356 type: AccountType.Twitter,
1357 status: AccountStatus.ABNORMAL,
1358 })
1359
1360 await expect(service.getValidCredential('account-1', 'user-1'))
1361 .rejects
1362 .toMatchObject({ code: ResponseCode.ChannelAccountNotAuthorized })
1363 expect(credentialService.getCredential).not.toHaveBeenCalled()
1364 })
1365
1366 it('marks the account offline when an owned account has no credential', async () => {
1367 const { service, credentialService, accountRepo, eventStream } = createService()
1368 accountRepo.getByIdAndUserId.mockResolvedValue({
1369 id: 'account-1',
1370 type: AccountType.Twitter,
1371 status: AccountStatus.NORMAL,
1372 })
1373 accountRepo.getAccountById.mockResolvedValue({
1374 id: 'account-1',
1375 type: AccountType.Twitter,
1376 })
1377 credentialService.getCredential.mockResolvedValue(null)
1378
1379 await expect(service.getValidCredential('account-1', 'user-1'))
1380 .rejects
1381 .toMatchObject({ code: ResponseCode.ChannelCredentialNotFound })
1382 expect(accountRepo.updateById).toHaveBeenCalledWith('account-1', { status: AccountStatus.ABNORMAL })
1383 expect(credentialService.invalidateCredential).toHaveBeenCalledWith('account-1')
1384 expect(eventStream.emit).toHaveBeenCalledWith(
1385 'channels',
1386 'channels.account.offline',
1387 { accountId: 'account-1', platform: AccountType.Twitter, reason: 'credential_not_found' },
1388 { source: 'auth' },
1389 )
1390 })
1391
1392 it('marks the account offline for non-retryable platform auth failures', async () => {
1393 const { service, accountRepo } = createService()
1394 const error = new ChannelPlatformException({
1395 code: ResponseCode.ChannelAccessTokenFailed,
1396 platform: AccountType.Twitter,
1397 category: PlatformErrorCategory.Auth,
1398 retryable: false,
1399 cause: {
1400 type: PlatformErrorCauseType.Http,
1401 httpStatus: 401,
1402 },
1403 })
1404
1405 await expect(service.markAccountOfflineForCredentialFailure('account-1', error, 'platform_auth_failed'))
1406 .resolves
1407 .toBe(true)
1408
1409 expect(accountRepo.updateById).toHaveBeenCalledWith('account-1', { status: AccountStatus.ABNORMAL })
1410 })
1411
1412 it('keeps the account online for retryable platform auth failures', async () => {
1413 const { service, accountRepo, credentialService } = createService()
1414 const error = new ChannelPlatformException({
1415 code: ResponseCode.ChannelAccessTokenFailed,
1416 platform: AccountType.Twitter,
1417 category: PlatformErrorCategory.Auth,
1418 retryable: true,
1419 cause: {
1420 type: PlatformErrorCauseType.Http,
1421 httpStatus: 503,
1422 },
1423 })
1424
1425 await expect(service.markAccountOfflineForCredentialFailure('account-1', error, 'platform_auth_failed'))
1426 .resolves
1427 .toBe(false)
1428
1429 expect(accountRepo.updateById).not.toHaveBeenCalled()
1430 expect(credentialService.invalidateCredential).not.toHaveBeenCalled()
1431 })
1432
1433 it('keeps the account online for platform permission failures', async () => {
1434 const { service, accountRepo, credentialService } = createService()
1435 const error = new ChannelPlatformException({
1436 code: ResponseCode.ChannelPlatformPermissionMissing,
1437 platform: AccountType.TikTok,
1438 category: PlatformErrorCategory.Permission,
1439 retryable: false,
1440 context: { endpoint: 'assertCreatorInteractionOptions' },
1441 cause: {
1442 type: PlatformErrorCauseType.Validation,
1443 },
1444 })
1445
1446 await expect(service.markAccountOfflineForCredentialFailure('account-1', error, 'platform_auth_failed'))
1447 .resolves
1448 .toBe(false)
1449
1450 expect(accountRepo.updateById).not.toHaveBeenCalled()
1451 expect(credentialService.invalidateCredential).not.toHaveBeenCalled()
1452 })
1453
1454 it('refreshes credential through the credential service', async () => {
1455 const { service, credentialService, accountRepo } = createService()
1456 accountRepo.getByIdAndUserId.mockResolvedValue({
1457 id: 'account-1',
1458 type: AccountType.Twitter,
1459 status: AccountStatus.NORMAL,
1460 })
1461 credentialService.tryRefresh.mockResolvedValue({
1462 accessToken: 'new-token',
1463 refreshToken: 'new-refresh-token',
1464 })
1465
1466 await expect(service.refreshCredential('account-1', 'user-1')).resolves.toEqual({
1467 accessToken: 'new-token',
1468 refreshToken: 'new-refresh-token',
1469 })
1470
1471 expect(credentialService.tryRefresh).toHaveBeenCalledWith({
1472 id: 'account-1',
1473 type: AccountType.Twitter,
1474 status: AccountStatus.NORMAL,
1475 })
1476 })
1477
1478 it('refreshes historical YouTube credentials that have no stored expiry', async () => {
1479 const { service, credentialService, accountRepo } = createService()
1480 accountRepo.getByIdAndUserId.mockResolvedValue({
1481 id: 'account-1',
1482 type: AccountType.YouTube,
1483 status: AccountStatus.NORMAL,
1484 })
1485 credentialService.getCredential.mockResolvedValue({
1486 accessToken: 'old-token',
1487 refreshToken: 'refresh-token',
1488 scope: 'https://www.googleapis.com/auth/youtube',
1489 })
1490 credentialService.tryRefresh.mockResolvedValue({
1491 accessToken: 'new-token',
1492 refreshToken: 'refresh-token',
1493 expiresAt: new Date('2026-01-01T00:00:00.000Z'),
1494 scope: 'https://www.googleapis.com/auth/youtube',
1495 })
1496
1497 await expect(service.getValidCredential('account-1', 'user-1')).resolves.toEqual({
1498 accessToken: 'new-token',
1499 refreshToken: 'refresh-token',
1500 expiresAt: new Date('2026-01-01T00:00:00.000Z'),
1501 scope: 'https://www.googleapis.com/auth/youtube',
1502 })
1503 expect(credentialService.tryRefresh).toHaveBeenCalledWith({
1504 id: 'account-1',
1505 type: AccountType.YouTube,
1506 status: AccountStatus.NORMAL,
1507 })
1508 })
1509
1510 it('fails direct credential refresh when another refresh owns the lock', async () => {
1511 const { service, credentialService, accountRepo } = createService()
1512 accountRepo.getByIdAndUserId.mockResolvedValue({
1513 id: 'account-1',
1514 type: AccountType.Twitter,
1515 status: AccountStatus.NORMAL,
1516 })
1517 credentialService.tryRefresh.mockResolvedValue(null)
1518
1519 await expect(service.refreshCredential('account-1', 'user-1'))
1520 .rejects
1521 .toMatchObject({ code: ResponseCode.ChannelAccessTokenFailed })
1522
1523 expect(credentialService.getCredential).not.toHaveBeenCalled()
1524 })
1525
1526 it('returns null for try credential refresh when another refresh owns the lock', async () => {
1527 const { service, credentialService, accountRepo } = createService()
1528 accountRepo.getByIdAndUserId.mockResolvedValue({
1529 id: 'account-1',
1530 type: AccountType.Twitter,
1531 status: AccountStatus.NORMAL,
1532 })
1533 credentialService.tryRefresh.mockResolvedValue(null)
1534
1535 await expect(service.tryRefreshCredential('account-1', 'user-1')).resolves.toBeNull()
1536
1537 expect(accountRepo.updateById).not.toHaveBeenCalled()
1538 expect(credentialService.invalidateCredential).not.toHaveBeenCalled()
1539 })
1540
1541 it('waits for a refreshed credential only while getting a valid credential', async () => {
1542 const { service, credentialService, accountRepo } = createService()
1543 accountRepo.getByIdAndUserId.mockResolvedValue({
1544 id: 'account-1',
1545 type: AccountType.Twitter,
1546 status: AccountStatus.NORMAL,
1547 })
1548 credentialService.getCredential.mockResolvedValue({
1549 accessToken: 'expired-token',
1550 refreshToken: 'refresh-token',
1551 expiresAt: 1,
1552 })
1553 const refreshedExpiresAt = Math.floor(Date.now() / 1000) + 3600
1554 credentialService.getCredential.mockResolvedValueOnce({
1555 accessToken: 'expired-token',
1556 refreshToken: 'refresh-token',
1557 expiresAt: 1,
1558 }).mockResolvedValueOnce({
1559 accessToken: 'new-token',
1560 refreshToken: 'new-refresh-token',
1561 expiresAt: refreshedExpiresAt,
1562 })
1563 credentialService.tryRefresh.mockResolvedValue(null)
1564
1565 await expect(service.getValidCredential('account-1', 'user-1')).resolves.toEqual({
1566 accessToken: 'new-token',
1567 refreshToken: 'new-refresh-token',
1568 expiresAt: new Date(refreshedExpiresAt * 1000),
1569 scope: undefined,
1570 })
1571 })
1572
1573 it('does not wait for a refreshed credential when refresh itself fails', async () => {
1574 const { service, credentialService, accountRepo } = createService()
1575 accountRepo.getByIdAndUserId.mockResolvedValue({
1576 id: 'account-1',
1577 type: AccountType.Twitter,
1578 status: AccountStatus.NORMAL,
1579 })
1580 credentialService.getCredential.mockResolvedValue({
1581 accessToken: 'expired-token',
1582 refreshToken: 'refresh-token',
1583 expiresAt: 1,
1584 })
1585 credentialService.tryRefresh.mockRejectedValue(new AppException(ResponseCode.ChannelRefreshTokenFailed))
1586
1587 await expect(service.getValidCredential('account-1', 'user-1'))
1588 .rejects
1589 .toMatchObject({ code: ResponseCode.ChannelRefreshTokenFailed })
1590
1591 expect(credentialService.getCredential).toHaveBeenCalledTimes(1)
1592 expect(accountRepo.updateById).toHaveBeenCalledWith('account-1', { status: AccountStatus.ABNORMAL })
1593 expect(credentialService.invalidateCredential).toHaveBeenCalledWith('account-1')
1594 })
1595
1596 it('marks the account offline when direct credential refresh fails', async () => {
1597 const { service, credentialService, accountRepo } = createService()
1598 accountRepo.getByIdAndUserId.mockResolvedValue({
1599 id: 'account-1',
1600 type: AccountType.Twitter,
1601 status: AccountStatus.NORMAL,
1602 })
1603 credentialService.tryRefresh.mockRejectedValue(new AppException(ResponseCode.ChannelRefreshTokenFailed))
1604
1605 await expect(service.refreshCredential('account-1', 'user-1'))
1606 .rejects
1607 .toMatchObject({ code: ResponseCode.ChannelRefreshTokenFailed })
1608
1609 expect(accountRepo.updateById).toHaveBeenCalledWith('account-1', { status: AccountStatus.ABNORMAL })
1610 expect(credentialService.invalidateCredential).toHaveBeenCalledWith('account-1')
1611 })
1612
1613 it('marks the account offline when try credential refresh fails', async () => {
1614 const { service, credentialService, accountRepo } = createService()
1615 accountRepo.getByIdAndUserId.mockResolvedValue({
1616 id: 'account-1',
1617 type: AccountType.Twitter,
1618 status: AccountStatus.NORMAL,
1619 })
1620 credentialService.tryRefresh.mockRejectedValue(new AppException(ResponseCode.ChannelRefreshTokenFailed))
1621
1622 await expect(service.tryRefreshCredential('account-1', 'user-1'))
1623 .rejects
1624 .toMatchObject({ code: ResponseCode.ChannelRefreshTokenFailed })
1625
1626 expect(accountRepo.updateById).toHaveBeenCalledWith('account-1', { status: AccountStatus.ABNORMAL })
1627 expect(credentialService.invalidateCredential).toHaveBeenCalledWith('account-1')
1628 })
1629 })
1630
1630 lines TYPESCRIPT