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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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: '当前域名尚未登记为登录回跳地址',
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user