refactor: 个人自用,移除登录与云同步全链路

站点只给本人用,云乐坊 SSO 授权、收藏/历史云同步、获客归因和账号切换记账都没有收益,却被登录状态检查和同步失败提示持续打扰。

- 删除 useYunlefunAuth / yunlefunAuth / yunlefunSsoConfig / yunlefunAuth 适配器、useUserDataSync / userDataSync / userData 适配器、三个 client 插件与 /auth/callback 回调页
- 抽出 adapters/cloudbase.ts,让「反馈一下」和匿名筛选信号不再依赖登录态;cook-feedback 云函数保留
- 删除 cook-user-data 云函数与 cook_user_data 权限规则,移除 @yunlefun/sso 依赖
- 「我的」页删掉登录按钮、账号卡、四个统计格和退出登录;「关于」页删掉项目作者、致谢名单和上游宣传链接
- 本地收藏/历史与导出导入保留,删除同步专用的 lastUid 记账

取舍见 docs/decisions/0005-drop-login-and-sync.md
This commit is contained in:
toor
2026-10-22 15:52:54 +08:00
committed by trae-solo-agent
parent 7408591fac
commit 8ff7b879b9
54 changed files with 214 additions and 3049 deletions
-158
View File
@@ -1,158 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
function browser(url = 'https://cook.yunle.fun/?utm_source=知乎&utm_medium=social&utm_campaign=秋季菜谱', referrer = 'https://www.baidu.com/search?q=private') {
const local = new Map<string, string>()
const session = new Map<string, string>()
const storage = (values: Map<string, string>) => ({
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
})
return {
document: { referrer },
localStorage: storage(local),
location: new URL(url),
sessionStorage: storage(session),
} as unknown as Pick<Window, 'location' | 'document' | 'localStorage' | 'sessionStorage'>
}
beforeEach(() => vi.resetModules())
describe('cook document acquisition', () => {
it.each([
'https://yunle.fun/',
'https://www.yunle.fun/login',
'https://apps.yunle.fun/',
'https://nested.apps.yunle.fun/',
'https://cook.yunyoujun.cn/',
'https://yunle.fun./login',
'https://cook.yunle.fun/random',
])('excludes internal ecosystem navigation from external acquisition: %s', async (referrer) => {
const { captureCookAcquisition } = await import('../apps/web/app/utils/acquisition')
const context = captureCookAcquisition(browser('https://cook.yunle.fun/', referrer), 1000)!
expect(context.firstTouch.referrerHost).toBe('')
expect(context.registerTouch.referrerHost).toBe('')
})
it.each(['https://github.com/YunYouJun/cook', 'https://www.zhihu.com/', 'https://notyunle.fun/'])(
'retains an actual external referrer: %s',
async (referrer) => {
const { captureCookAcquisition } = await import('../apps/web/app/utils/acquisition')
const context = captureCookAcquisition(browser('https://cook.yunle.fun/', referrer), 1000)!
expect(context.registerTouch.referrerHost).toBe(new URL(referrer).hostname)
},
)
it('keeps first touch for 30 days while updating the current document entry', async () => {
const { captureCookAcquisition } = await import('../apps/web/app/utils/acquisition')
const entry = browser()
const first = captureCookAcquisition(entry, 1000)!
entry.location = new URL('https://cook.yunle.fun/random?utm_source=github') as unknown as Location
const second = captureCookAcquisition(entry, 2000)!
expect(second.firstTouch).toEqual(first.firstTouch)
expect(second.registerTouch.landingPath).toBe('/random')
expect(second.registerTouch.utmSource).toBe('github')
expect(second.registerTouch.referrerHost).toBe('www.baidu.com')
expect(JSON.stringify(second)).not.toContain('q=private')
const expired = captureCookAcquisition(entry, 1000 + 30 * 86400000)!
expect(expired.firstTouch).toEqual(expired.registerTouch)
expect(expired.firstTouch.utmSource).toBe('github')
})
it('preserves the pre-auth entry through a callback and never captures OAuth referrers', async () => {
const { captureCookAcquisition, readCookAcquisition } = await import('../apps/web/app/utils/acquisition')
const entry = browser()
const context = captureCookAcquisition(entry, 1000)
entry.location = new URL('https://cook.yunle.fun/auth/callback?code=private#ylf_sso=private') as unknown as Location
Object.assign(entry.document, { referrer: 'https://github.com/login/oauth' })
expect(captureCookAcquisition(entry, 2000)).toEqual(context)
expect(readCookAcquisition(entry, 2000)).toEqual(context)
expect(JSON.stringify(context)).not.toContain('private')
})
it('does not invent attribution for a callback without an earlier document entry', async () => {
const { captureCookAcquisition } = await import('../apps/web/app/utils/acquisition')
expect(captureCookAcquisition(browser('https://cook.yunle.fun/auth/callback'), 1000)).toBeNull()
})
it.each([null, '{broken', JSON.stringify({ capturedAt: -1 })])('does not restore a readable deleted or invalid entry from memory: %s', async (stored) => {
const { captureCookAcquisition, readCookAcquisition } = await import('../apps/web/app/utils/acquisition')
const entry = browser()
expect(captureCookAcquisition(entry, 1000)?.registerTouch.utmSource).toBe('知乎')
entry.sessionStorage.getItem = () => stored
expect(readCookAcquisition(entry, 2000)).toBeNull()
entry.sessionStorage.getItem = () => {
throw new Error('storage blocked')
}
expect(readCookAcquisition(entry, 3000)).toBeNull()
})
it('uses the current entry when readable first touch is cleared, without reviving its old campaign', async () => {
const { captureCookAcquisition, readCookAcquisition } = await import('../apps/web/app/utils/acquisition')
const entry = browser()
captureCookAcquisition(entry, 1000)
entry.location = new URL('https://cook.yunle.fun/random?utm_source=github') as unknown as Location
captureCookAcquisition(entry, 2000)
entry.localStorage.getItem = () => null
expect(readCookAcquisition(entry, 3000)?.firstTouch.utmSource).toBe('github')
entry.localStorage.getItem = () => {
throw new Error('storage blocked')
}
expect(readCookAcquisition(entry, 4000)?.firstTouch.utmSource).toBe('github')
})
it('keeps a validated memory first touch across blocked storage and expires stale entry data', async () => {
const { captureCookAcquisition, readCookAcquisition } = await import('../apps/web/app/utils/acquisition')
const entry = browser()
for (const storage of [entry.localStorage, entry.sessionStorage]) {
storage.getItem = () => {
throw new Error('storage blocked')
}
storage.setItem = () => {
throw new Error('storage blocked')
}
}
captureCookAcquisition(entry, 1000)
entry.location = new URL('https://cook.yunle.fun/random?utm_source=github') as unknown as Location
const current = captureCookAcquisition(entry, 2000)!
expect(current.firstTouch.utmSource).toBe('知乎')
expect(current.registerTouch.utmSource).toBe('github')
expect(readCookAcquisition(entry, 2000 + 30 * 86400000)).toBeNull()
})
it('uses the new entry when a failed write leaves the old value readable, then respects deletion', async () => {
const { captureCookAcquisition, readCookAcquisition } = await import('../apps/web/app/utils/acquisition')
const entry = browser('https://cook.yunle.fun/?utm_source=old')
captureCookAcquisition(entry, 1000)
entry.sessionStorage.setItem = () => {
throw new Error('quota exceeded')
}
entry.location = new URL('https://cook.yunle.fun/random?utm_source=new') as unknown as Location
expect(captureCookAcquisition(entry, 2000)?.registerTouch.utmSource).toBe('new')
expect(readCookAcquisition(entry, 3000)?.registerTouch.utmSource).toBe('new')
expect(readCookAcquisition(entry, 3000)?.firstTouch.utmSource).toBe('old')
entry.sessionStorage.getItem = () => null
expect(readCookAcquisition(entry, 4000)).toBeNull()
entry.sessionStorage.getItem = () => {
throw new Error('storage blocked')
}
expect(readCookAcquisition(entry, 5000)).toBeNull()
})
it('ignores credentials, invalid UTM and storage failures without blocking authentication', async () => {
const { captureCookAcquisition, readCookAcquisition } = await import('../apps/web/app/utils/acquisition')
const entry = browser('https://cook.yunle.fun/?utm_source=hello%0Aworld&token=private', 'https://user:pass@example.com/path')
for (const storage of ['localStorage', 'sessionStorage']) {
Object.defineProperty(entry, storage, {
get() {
throw new Error('storage blocked')
},
})
}
const context = captureCookAcquisition(entry, 1000)!
expect(context.registerTouch.referrerHost).toBe('')
expect(context.registerTouch.utmSource).toBe('')
expect(readCookAcquisition(entry, 2000)).toEqual(context)
expect(JSON.stringify(context)).not.toContain('private')
})
})
-125
View File
@@ -1,125 +0,0 @@
import { createRequire } from 'node:module'
import { beforeEach, describe, expect, it, vi } from 'vitest'
interface FunctionModule {
main: (event?: Record<string, unknown>) => Promise<Record<string, unknown>>
_internal: {
COLLECTION: string
LIST_LIMITS: Record<string, number>
MAX_DOCUMENT_BYTES: number
readDocument: (value: unknown) => Record<string, unknown> | null
setAppForTest: (app: unknown) => void
}
}
const require = createRequire(import.meta.url)
const fn = require('../cloudfunctions/cook-user-data/index.js') as FunctionModule
function createApp(userInfo: Record<string, unknown> | (() => never)) {
const docs = new Map<string, Record<string, unknown>>()
const doc = vi.fn((id: string) => ({
get: async () => ({ data: docs.has(id) ? [{ _id: id, _openid: 'x', ...docs.get(id) }] : [] }),
set: async (data: Record<string, unknown>) => {
docs.set(id, data)
return { updated: 1 }
},
}))
const collection = vi.fn(() => ({ doc }))
const app = {
auth: () => ({ getUserInfo: typeof userInfo === 'function' ? userInfo : () => userInfo }),
database: () => ({ collection }),
}
fn._internal.setAppForTest(app)
return { collection, doc, docs }
}
const document = {
version: 1,
favorites: [{ id: 1, time: 1 }],
favoriteRemovals: [],
histories: [{ recipe: { name: 'A', stuff: [], tools: [] }, time: 2 }],
historyRemovals: [],
historyClearedAt: 0,
}
describe('cook-user-data function', () => {
beforeEach(() => {
vi.useRealTimers()
})
it('rejects callers without a real account', async () => {
for (const info of [{}, { uid: '' }, { uid: 'anon' }, { uid: 'u1', isAnonymous: true }]) {
const { doc } = createApp(info)
await expect(fn.main({ action: 'get' })).resolves.toEqual({ ok: false, code: 'LOGIN_REQUIRED' })
expect(doc).not.toHaveBeenCalled()
}
createApp(() => {
throw new Error('no context')
})
await expect(fn.main({ action: 'get' })).resolves.toEqual({ ok: false, code: 'LOGIN_REQUIRED' })
})
it('always uses the caller uid as the document id', async () => {
const { collection, doc, docs } = createApp({ uid: 'u1' })
const result = await fn.main({ action: 'set', document, uid: 'victim', _id: 'victim' })
expect(result).toMatchObject({ ok: true })
expect(collection).toHaveBeenCalledWith('cook_user_data')
expect(doc.mock.calls.map(call => call[0])).toEqual(['u1'])
expect(docs.has('victim')).toBe(false)
expect(docs.get('u1')).toMatchObject({ version: 1, favorites: [{ id: 1, time: 1 }] })
})
it('returns the caller document without CloudBase metadata', async () => {
createApp({ uid: 'u1' })
await fn.main({ action: 'set', document })
const result = await fn.main({ action: 'get' })
expect(result.ok).toBe(true)
expect(result.document).toMatchObject({ version: 1, favorites: [{ id: 1, time: 1 }] })
expect(result.document).not.toHaveProperty('_id')
expect(result.document).not.toHaveProperty('_openid')
})
it('returns null for an account without a document', async () => {
createApp({ uid: 'u2' })
await expect(fn.main({ action: 'get' })).resolves.toEqual({ ok: true, document: null })
})
it('rejects malformed or oversized documents', async () => {
const { docs } = createApp({ uid: 'u1' })
const bad = [
null,
{ ...document, version: 2 },
{ ...document, favorites: 'x' },
{ ...document, favorites: [1] },
{ ...document, histories: Array.from({ length: fn._internal.LIST_LIMITS.histories! + 1 }, () => ({})) },
{ ...document, historyClearedAt: -1 },
{ ...document, favorites: [{ id: 1, note: 'x'.repeat(fn._internal.MAX_DOCUMENT_BYTES) }] },
]
for (const input of bad)
await expect(fn.main({ action: 'set', document: input })).resolves.toEqual({ ok: false, code: 'INVALID_DOCUMENT' })
expect(docs.size).toBe(0)
})
it('drops unknown top-level fields', () => {
expect(fn._internal.readDocument({ ...document, _id: 'victim', admin: true })).toEqual({ ...document })
})
it('rejects unknown actions and hides internal errors', async () => {
createApp({ uid: 'u1' })
await expect(fn.main({ action: 'drop' })).resolves.toEqual({ ok: false, code: 'UNKNOWN_ACTION' })
fn._internal.setAppForTest({
auth: () => ({ getUserInfo: () => ({ uid: 'u1' }) }),
database: () => {
throw new Error('db down')
},
})
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
await expect(fn.main({ action: 'get' })).resolves.toEqual({ ok: false, code: 'INTERNAL_ERROR' })
error.mockRestore()
})
})
+3 -54
View File
@@ -1,15 +1,12 @@
import type { RecipeItem } from '../apps/web/app/types'
import type { DbRecipeItem } from '../apps/web/app/utils/db'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createCloudbaseUserDataRemote, USER_DATA_FUNCTION } from '../apps/web/app/adapters/userData'
import { favoriteEntries, setFavoriteEntries, toggleFavorite } from '../apps/web/app/composables/store/favorite'
import { addRecipeHistory, clearRecipeHistories, recipeHistories, removeRecipeHistory } from '../apps/web/app/composables/store/history'
import { exportLocalUserData, importLocalUserData, localUserData } from '../apps/web/app/composables/store/userData'
import { exportLocalUserData, importLocalUserData } from '../apps/web/app/composables/store/userData'
import { userDataMeta } from '../apps/web/app/composables/store/userDataMeta'
const NOW = 1_800_000_000_000
function recipe(name: string, id?: number): DbRecipeItem {
function recipe(name: string, id?: number) {
return { id, name, stuff: [], tools: [] }
}
@@ -17,7 +14,7 @@ beforeEach(() => {
vi.useFakeTimers({ now: NOW })
setFavoriteEntries([])
recipeHistories.value = []
userDataMeta.value = { favoriteRemovals: [], historyRemovals: [], historyClearedAt: 0, lastUid: '' }
userDataMeta.value = { favoriteRemovals: [], historyRemovals: [], historyClearedAt: 0 }
return () => vi.useRealTimers()
})
@@ -88,51 +85,3 @@ describe('export / import', () => {
expect(favoriteEntries.value).toEqual([])
})
})
describe('localUserData', () => {
it('only rewrites the last uid when it changes', () => {
const before = userDataMeta.value
localUserData.writeLastUid('')
expect(userDataMeta.value).toBe(before)
localUserData.writeLastUid('u1')
expect(localUserData.readLastUid()).toBe('u1')
})
})
describe('createCloudbaseUserDataRemote', () => {
const document = {
version: 1 as const,
favorites: [{ id: 1, time: NOW }],
favoriteRemovals: [],
histories: [{ recipe: { name: 'A', stuff: [], tools: [] } as RecipeItem, time: NOW }],
historyRemovals: [],
historyClearedAt: 0,
updatedAt: NOW,
}
it('reads through the cook-user-data function without naming a uid', async () => {
const callFunction = vi.fn(async () => ({ ok: true, document: { favorites: [] } }))
const remote = createCloudbaseUserDataRemote(callFunction)
await expect(remote.read('u1')).resolves.toEqual({ favorites: [] })
expect(callFunction).toHaveBeenCalledWith(USER_DATA_FUNCTION, { action: 'get' })
})
it('returns null when the account has no document', async () => {
const remote = createCloudbaseUserDataRemote(async () => ({ ok: true, document: null }))
await expect(remote.read('u1')).resolves.toBeNull()
})
it('writes the whole document', async () => {
const callFunction = vi.fn(async () => ({ ok: true, updatedAt: NOW }))
await createCloudbaseUserDataRemote(callFunction).write('u1', document)
expect(callFunction).toHaveBeenCalledWith(USER_DATA_FUNCTION, { action: 'set', document })
})
it('surfaces function failures', async () => {
const remote = createCloudbaseUserDataRemote(async () => ({ ok: false, code: 'LOGIN_REQUIRED' }))
await expect(remote.read('u1')).rejects.toThrow('LOGIN_REQUIRED')
await expect(remote.write('u1', document)).rejects.toThrow('LOGIN_REQUIRED')
await expect(createCloudbaseUserDataRemote(async () => null).read('u1')).rejects.toThrow('unknown')
})
})
-185
View File
@@ -1,185 +0,0 @@
import type { UserData } from '../apps/web/app/utils/userData'
import type { UserDataDocument, UserDataLocal, UserDataRemote } from '../apps/web/app/utils/userDataSync'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createEmptyUserData } from '../apps/web/app/utils/userData'
import { createUserDataSyncScheduler, syncUserData, UserDataSyncError } from '../apps/web/app/utils/userDataSync'
const NOW = 1_800_000_000_000
function createLocal(initial: Partial<UserData> = {}, lastUid = '') {
const state = { data: { ...createEmptyUserData(), ...initial }, lastUid, writes: 0 }
const local: UserDataLocal = {
read: () => state.data,
write: (data) => {
state.data = data
state.writes += 1
},
readLastUid: () => state.lastUid,
writeLastUid: (uid) => {
state.lastUid = uid
},
}
return { local, state }
}
function createRemote(initial: Record<string, unknown> = {}) {
const docs = new Map<string, unknown>(Object.entries(initial))
const write = vi.fn(async (uid: string, document: UserDataDocument) => {
docs.set(uid, JSON.parse(JSON.stringify(document)))
})
const remote: UserDataRemote = {
read: async uid => docs.get(uid) ?? null,
write,
}
return { docs, remote, write }
}
describe('syncUserData', () => {
it('merges local data into a new cloud document on first sync', async () => {
const { local, state } = createLocal({ favorites: [{ id: 1, time: NOW - 1 }] })
const { docs, remote } = createRemote()
await syncUserData('u1', local, remote, NOW)
expect(docs.get('u1')).toMatchObject({ version: 1, updatedAt: NOW, favorites: [{ id: 1, time: NOW - 1 }] })
expect(state.lastUid).toBe('u1')
expect(state.writes).toBe(0)
})
it('merges cloud and local for the same account', async () => {
const { local, state } = createLocal({ favorites: [{ id: 1, time: NOW - 2 }] }, 'u1')
const { docs, remote } = createRemote({ u1: { version: 1, favorites: [{ id: 2, time: NOW - 1 }] } })
await syncUserData('u1', local, remote, NOW)
expect(state.data.favorites.map(f => f.id)).toEqual([1, 2])
expect((docs.get('u1') as UserData).favorites.map(f => f.id)).toEqual([1, 2])
})
it('does not write anything when both sides already agree', async () => {
const value = { favorites: [{ id: 1, time: NOW - 1 }] }
const { local, state } = createLocal(value, 'u1')
const { remote, write } = createRemote({ u1: { version: 1, ...value } })
await syncUserData('u1', local, remote, NOW)
expect(write).not.toHaveBeenCalled()
expect(state.writes).toBe(0)
})
it('replaces local data instead of merging after an account switch', async () => {
const { local, state } = createLocal({ favorites: [{ id: 1, time: NOW - 2 }] }, 'u1')
const { docs, remote, write } = createRemote({ u2: { version: 1, favorites: [{ id: 2, time: NOW - 1 }] } })
await syncUserData('u2', local, remote, NOW)
expect(state.data.favorites.map(f => f.id)).toEqual([2])
expect(state.lastUid).toBe('u2')
expect(write).not.toHaveBeenCalled()
expect(docs.has('u1')).toBe(false)
})
it('never overwrites an unrecognised cloud document', async () => {
const { local, state } = createLocal({ favorites: [{ id: 1, time: NOW - 1 }] })
const { remote, write } = createRemote({ u1: { version: 99 } })
await expect(syncUserData('u1', local, remote, NOW)).rejects.toMatchObject({ code: 'invalid_remote' })
expect(write).not.toHaveBeenCalled()
expect(state.lastUid).toBe('')
})
it('keeps local data when the cloud is unreachable', async () => {
const { local, state } = createLocal({ favorites: [{ id: 1, time: NOW - 1 }] })
const remote: UserDataRemote = {
read: async () => {
throw new Error('offline')
},
write: vi.fn(),
}
const error = await syncUserData('u1', local, remote, NOW).catch(e => e)
expect(error).toBeInstanceOf(UserDataSyncError)
expect(error.code).toBe('remote_failed')
expect(state.data.favorites).toEqual([{ id: 1, time: NOW - 1 }])
})
})
describe('createUserDataSyncScheduler', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('coalesces requests made before the delay elapses', async () => {
const run = vi.fn(async () => {})
const scheduler = createUserDataSyncScheduler({ run })
scheduler.request(2000)
scheduler.request(2000)
await vi.advanceTimersByTimeAsync(1999)
expect(run).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(run).toHaveBeenCalledTimes(1)
})
it('runs again after a request made during a run', async () => {
let finish!: () => void
const run = vi.fn(() => new Promise<void>((resolve) => {
finish = resolve
}))
const scheduler = createUserDataSyncScheduler({ run })
scheduler.request()
await vi.advanceTimersByTimeAsync(0)
scheduler.request(2000)
finish()
await vi.advanceTimersByTimeAsync(0)
expect(run).toHaveBeenCalledTimes(2)
})
it('retries failures with exponential backoff', async () => {
const onError = vi.fn()
const run = vi.fn()
.mockRejectedValueOnce(new Error('a'))
.mockRejectedValueOnce(new Error('b'))
.mockResolvedValue(undefined)
const scheduler = createUserDataSyncScheduler({ run, onError })
scheduler.request()
await vi.advanceTimersByTimeAsync(0)
expect(run).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1000)
expect(run).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1999)
expect(run).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(run).toHaveBeenCalledTimes(3)
await vi.advanceTimersByTimeAsync(60_000)
expect(run).toHaveBeenCalledTimes(3)
expect(onError).toHaveBeenCalledTimes(2)
})
it('stops retrying after stop()', async () => {
const run = vi.fn().mockRejectedValue(new Error('offline'))
const scheduler = createUserDataSyncScheduler({ run })
scheduler.request()
await vi.advanceTimersByTimeAsync(0)
scheduler.stop()
await vi.advanceTimersByTimeAsync(60_000)
expect(run).toHaveBeenCalledTimes(1)
})
})
describe('cook_user_data permission', () => {
it('keeps the collection admin-only so only the cloud function can access it', async () => {
const { createRequire } = await import('node:module')
const fn = createRequire(import.meta.url)('../cloudfunctions/cook-user-data/index.js') as { _internal: { COLLECTION: string } }
const { default: rule } = await import('../scripts/cloudbase-rules/cook_user_data.json')
expect(rule.resourceId).toBe(fn._internal.COLLECTION)
expect(rule.permission).toBe('ADMINONLY')
expect(rule).not.toHaveProperty('securityRule')
})
})
-152
View File
@@ -1,152 +0,0 @@
import type { SsoAuthorizationResult } from '@yunlefun/sso'
import type {
YunlefunAccount,
YunlefunAuthorizationAdapter,
YunlefunIdentityAdapter,
} from '../apps/web/app/utils/yunlefunAuth'
import { describe, expect, it } from 'vitest'
import {
createYunlefunAuthController,
} from '../apps/web/app/utils/yunlefunAuth'
const authorization: SsoAuthorizationResult = {
ok: true,
clientId: 'cook-web',
code: 'c'.repeat(43),
codeVerifier: 'v'.repeat(43),
issuer: 'https://www.yunle.fun',
nonce: 'n'.repeat(43),
redirectUri: 'https://cook.yunyoujun.cn/auth/callback',
scope: ['identity:bootstrap'],
}
const account: YunlefunAccount = {
displayName: '云游君',
uid: 'user-1',
}
describe('yunlefun auth controller', () => {
it('adopts an initial authorization result through its public state', async () => {
const web = fakeAuthorizationAdapter(authorization)
const identity = fakeIdentityAdapter()
const controller = createYunlefunAuthController({
identity,
web,
})
await controller.initialize()
expect(controller.snapshot()).toEqual({
account,
errorMessage: '',
status: 'signed-in',
})
expect(identity.adopted).toEqual([authorization])
})
it('opens the web authorization surface', async () => {
const web = fakeAuthorizationAdapter()
const controller = createYunlefunAuthController({
identity: fakeIdentityAdapter(),
web,
})
await controller.signIn()
expect(web.started).toBe(1)
expect(controller.snapshot().status).toBe('signing-in')
})
it('adopts a host authorization without leaving the current page', async () => {
const web = fakeAuthorizationAdapter(null, authorization)
const identity = fakeIdentityAdapter()
const controller = createYunlefunAuthController({
identity,
web,
})
await controller.signIn()
expect(identity.adopted).toEqual([authorization])
expect(controller.snapshot()).toEqual({
account,
errorMessage: '',
status: 'signed-in',
})
})
it('clears both the identity session and public account on sign-out', async () => {
const identity = fakeIdentityAdapter(account)
const controller = createYunlefunAuthController({
identity,
web: fakeAuthorizationAdapter(),
})
await controller.initialize()
await controller.signOut()
expect(identity.signOutCount).toBe(1)
expect(controller.snapshot()).toEqual({
account: null,
errorMessage: '',
status: 'signed-out',
})
})
it('keeps the authenticated account visible when sign-out fails', async () => {
const identity = fakeIdentityAdapter(account)
identity.signOut = async () => {
identity.signOutCount += 1
throw new Error('remote sign-out failed')
}
const controller = createYunlefunAuthController({
identity,
web: fakeAuthorizationAdapter(),
})
await controller.initialize()
await expect(controller.signOut()).resolves.toBeUndefined()
expect(controller.snapshot()).toEqual({
account,
errorMessage: '退出登录失败,请稍后重试',
status: 'error',
})
})
})
function fakeAuthorizationAdapter(
initial: SsoAuthorizationResult | null = null,
startedResult: SsoAuthorizationResult | null = null,
): YunlefunAuthorizationAdapter & { started: number } {
return {
started: 0,
async consumeInitial() {
return initial
},
async start() {
this.started += 1
return startedResult
},
}
}
function fakeIdentityAdapter(restored: YunlefunAccount | null = null): YunlefunIdentityAdapter & {
adopted: SsoAuthorizationResult[]
signOutCount: number
} {
return {
adopted: [],
signOutCount: 0,
async adopt(nextAuthorization) {
this.adopted.push(nextAuthorization)
return account
},
async restore() {
return restored
},
async signOut() {
this.signOutCount += 1
},
}
}
-119
View File
@@ -1,119 +0,0 @@
import type { AcquisitionContext, SsoAuthorizationResult } from '@yunlefun/sso'
import type { YunlefunSsoConfig } from '../apps/web/app/utils/yunlefunSsoConfig'
import { SsoIdentityAdoptionError } from '@yunlefun/sso/browser'
import { describe, expect, it, vi } from 'vitest'
import {
createCloudbaseIdentityAdapter,
createWebYunlefunAuthorizationAdapter,
} from '../apps/web/app/adapters/yunlefunAuth'
const config: YunlefunSsoConfig = {
cloudbaseEnv: 'yunlefun-production',
exchange: {
exchangeUrl: 'https://api.yunle.fun/sso-ticket',
},
redirect: {
clientId: 'cook-web',
redirectUri: 'https://cook.yunyoujun.cn/auth/callback',
scope: ['identity:bootstrap'],
ssoOrigin: 'https://www.yunle.fun',
},
}
const hostAuthorization: SsoAuthorizationResult = {
ok: true,
clientId: 'cook-web',
code: 'c'.repeat(43),
codeVerifier: 'v'.repeat(43),
issuer: 'https://www.yunle.fun',
nonce: 'n'.repeat(43),
redirectUri: 'https://cook.yunyoujun.cn/auth/callback',
scope: ['identity:bootstrap'],
}
const touch = {
capturedAt: 1000,
landingHost: 'cook.yunle.fun',
landingPath: '/random',
referrerHost: 'www.baidu.com',
utmSource: 'baidu',
utmMedium: 'organic',
utmCampaign: '',
}
const acquisition: AcquisitionContext = { version: 1, firstTouch: touch, registerTouch: touch }
describe('web authorization adapter', () => {
it('passes the same captured context to host authorization and redirect fallback', async () => {
const requestHostAuthorization = vi.fn(async () => null)
const startRedirect = vi.fn()
const adapter = createWebYunlefunAuthorizationAdapter(config, {
readAcquisition: () => acquisition,
requestHostAuthorization,
startRedirect,
})
await adapter.start()
expect(requestHostAuthorization).toHaveBeenCalledWith({ ...config.redirect, prompt: 'consent', acquisition })
expect(startRedirect).toHaveBeenCalledWith({ ...config.redirect, acquisition })
})
it('uses the YunLeFun host consent sheet without starting a redirect', async () => {
const requestHostAuthorization = vi.fn(async () => hostAuthorization)
const startRedirect = vi.fn()
const adapter = createWebYunlefunAuthorizationAdapter(config, {
requestHostAuthorization,
startRedirect,
})
await expect(adapter.start()).resolves.toEqual(hostAuthorization)
expect(requestHostAuthorization).toHaveBeenCalledWith({
...config.redirect,
prompt: 'consent',
})
expect(startRedirect).not.toHaveBeenCalled()
})
it('falls back to the top-level web redirect outside the YunLeFun host', async () => {
const startRedirect = vi.fn()
const adapter = createWebYunlefunAuthorizationAdapter(config, {
requestHostAuthorization: async () => null,
startRedirect,
})
await expect(adapter.start()).resolves.toBeNull()
expect(startRedirect).toHaveBeenCalledWith(config.redirect)
})
it('does not bypass an explicit host denial with a web redirect', async () => {
const startRedirect = vi.fn()
const adapter = createWebYunlefunAuthorizationAdapter(config, {
requestHostAuthorization: async () => {
throw new SsoIdentityAdoptionError(
'Host authorization was denied',
'access_denied',
)
},
startRedirect,
})
await expect(adapter.start()).resolves.toEqual({
ok: false,
reason: 'access_denied',
})
expect(startRedirect).not.toHaveBeenCalled()
})
})
describe('cloudbase identity adapter', () => {
it('rejects a sign-out response that contains a CloudBase error', async () => {
const identity = createCloudbaseIdentityAdapter(config, {
getAuth: async () => ({
getSession: async () => ({ data: { session: null } }),
getUser: async () => ({ data: { user: null } }),
signInWithCustomTicket: async () => ({}),
signOut: async () => ({ error: new Error('remote sign-out failed') }),
}),
})
await expect(identity.signOut()).rejects.toThrow('CloudBase sign-out failed')
})
})
-66
View File
@@ -1,66 +0,0 @@
import { describe, expect, it } from 'vitest'
import { resolveYunlefunSsoConfig } from '../apps/web/app/utils/yunlefunSsoConfig'
const validConfig = {
clientId: 'cook-web',
cloudbaseEnv: 'yunlefun-8g7ybcxc7345c490',
exchangeUrl: 'https://api.yunle.fun/sso-ticket',
redirectUri: 'https://cook.yunyoujun.cn/auth/callback',
redirectUris: 'https://cook.yunyoujun.cn/auth/callback,https://cook.yunle.fun/auth/callback',
scope: 'identity:bootstrap',
ssoOrigin: 'https://www.yunle.fun',
}
describe('resolveYunlefunSsoConfig', () => {
it('accepts the exact registered production web origin', () => {
expect(resolveYunlefunSsoConfig(validConfig, {
currentOrigin: 'https://cook.yunyoujun.cn',
})).toEqual({
ok: true,
config: {
cloudbaseEnv: 'yunlefun-8g7ybcxc7345c490',
exchange: {
exchangeUrl: 'https://api.yunle.fun/sso-ticket',
},
redirect: {
clientId: 'cook-web',
redirectUri: 'https://cook.yunyoujun.cn/auth/callback',
scope: ['identity:bootstrap'],
ssoOrigin: 'https://www.yunle.fun',
},
},
})
})
it('selects the registered callback for the current production web origin', () => {
expect(resolveYunlefunSsoConfig(validConfig, {
currentOrigin: 'https://cook.yunle.fun',
})).toMatchObject({
ok: true,
config: {
redirect: {
clientId: 'cook-web',
redirectUri: 'https://cook.yunle.fun/auth/callback',
},
},
})
})
it('fails closed on an unregistered HTTP development origin', () => {
expect(resolveYunlefunSsoConfig(validConfig, {
currentOrigin: 'http://127.0.0.1:4173',
})).toEqual({
ok: false,
message: '当前域名尚未登记为登录回跳地址',
})
})
it('fails closed on an unregistered HTTPS origin', () => {
expect(resolveYunlefunSsoConfig(validConfig, {
currentOrigin: 'https://cook.example.com',
})).toEqual({
ok: false,
message: '当前域名尚未登记为登录回跳地址',
})
})
})