mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
2657f82ef1
* feat(auth): add contextual OAuth workspace grants * fix(auth): register RFC 9396 detail types * fix(openapi): restore delegated CLI auth profile * fix(store): recover paid capacity fulfillment * fix(auth): close OAuth contract gaps * fix(store): resume verified x402 settlements * chore(deps): update zpan cloud sdk to 2.5.2 * test(store): cover verified settlement recovery * fix(auth): localize standard consent scopes * fix(docker): include dependency patches before install * fix(store): harden x402 purchase responses * test(auth): cover OAuth authorization boundaries * docs: add PR 550 verification screenshots * chore: remove temporary verification screenshots * docs(openapi): document exhausted capacity response
261 lines
9.5 KiB
TypeScript
261 lines
9.5 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { Platform } from '../platform/interface'
|
|
import type {
|
|
EntitlementResult,
|
|
ImageUpload,
|
|
ImageUploadResult,
|
|
ProfileRepo,
|
|
PublicUser,
|
|
QuotaEntitlementItem,
|
|
UserAdminRepo,
|
|
UserOperationFailure,
|
|
} from './ports'
|
|
import {
|
|
type AvatarDeps,
|
|
getPublicProfile,
|
|
grantUserEntitlement,
|
|
listUserEntitlements,
|
|
removeAvatar,
|
|
revokeUserEntitlement,
|
|
type UserDeps,
|
|
updateAvatar,
|
|
updateUserEntitlement,
|
|
} from './user'
|
|
|
|
const sampleEntitlement = {
|
|
id: 'ent-1',
|
|
orgId: 'org-1',
|
|
resourceType: 'storage',
|
|
bytes: 1000,
|
|
expiresAt: new Date('2030-01-01T00:00:00.000Z'),
|
|
} as QuotaEntitlementItem
|
|
|
|
const sampleResult: EntitlementResult = { orgId: 'org-1', entitlement: sampleEntitlement }
|
|
|
|
const failure: UserOperationFailure = { error: 'User not found: missing', status: 404 }
|
|
|
|
// Every UserAdminRepo method is stubbed so a usecase can override just the one it exercises.
|
|
function makeDeps(userAdmin: Partial<UserAdminRepo> = {}) {
|
|
const repo: UserAdminRepo = {
|
|
isBanned: async () => false,
|
|
getSiteRole: async () => null,
|
|
findActiveUserIdByUsername: async () => null,
|
|
listUserPersonalEntitlements: async () => ({ orgId: 'org-1', items: [] }),
|
|
grantUserPersonalEntitlement: async () => sampleResult,
|
|
updateUserPersonalEntitlement: async () => sampleResult,
|
|
revokeUserPersonalEntitlement: async () => sampleResult,
|
|
requireOrg: async () => ({ orgId: 'org-1' }),
|
|
listOrgEntitlements: async () => ({ orgId: 'org-1', items: [] }),
|
|
grantOrgEntitlement: async () => sampleResult,
|
|
updateOrgEntitlement: async () => sampleResult,
|
|
revokeOrgEntitlement: async () => sampleResult,
|
|
...userAdmin,
|
|
}
|
|
const deps: UserDeps = { userAdmin: repo }
|
|
return { deps }
|
|
}
|
|
|
|
beforeEach(() => vi.clearAllMocks())
|
|
|
|
describe('user usecase', () => {
|
|
describe('listUserEntitlements', () => {
|
|
it('returns the repo result', async () => {
|
|
const result = { orgId: 'org-1', items: [sampleEntitlement] }
|
|
const { deps } = makeDeps({ listUserPersonalEntitlements: async () => result })
|
|
expect(await listUserEntitlements(deps, 'u-1')).toEqual({ ok: true, result })
|
|
})
|
|
|
|
it('threads the repo failure outward', async () => {
|
|
const { deps } = makeDeps({ listUserPersonalEntitlements: async () => failure })
|
|
expect(await listUserEntitlements(deps, 'x')).toEqual({ ok: false, failure })
|
|
})
|
|
})
|
|
|
|
describe('grantUserEntitlement', () => {
|
|
it('grants and forwards input', async () => {
|
|
const grant = vi.fn(async () => sampleResult)
|
|
const { deps } = makeDeps({ grantUserPersonalEntitlement: grant })
|
|
const expiresAt = new Date('2030-01-01T00:00:00.000Z')
|
|
const out = await grantUserEntitlement(deps, {
|
|
adminUserId: 'admin',
|
|
targetUserId: 'u-1',
|
|
resourceType: 'storage',
|
|
bytes: 1000,
|
|
expiresAt,
|
|
note: 'bonus',
|
|
})
|
|
expect(out).toEqual({ ok: true, result: sampleResult })
|
|
expect(grant).toHaveBeenCalledWith({
|
|
adminUserId: 'admin',
|
|
targetUserId: 'u-1',
|
|
resourceType: 'storage',
|
|
bytes: 1000,
|
|
expiresAt,
|
|
note: 'bonus',
|
|
})
|
|
})
|
|
|
|
it('threads the repo failure outward', async () => {
|
|
const { deps } = makeDeps({ grantUserPersonalEntitlement: async () => failure })
|
|
const out = await grantUserEntitlement(deps, {
|
|
adminUserId: 'admin',
|
|
targetUserId: 'x',
|
|
resourceType: 'storage',
|
|
bytes: 1000,
|
|
})
|
|
expect(out).toEqual({ ok: false, failure })
|
|
})
|
|
})
|
|
|
|
describe('updateUserEntitlement', () => {
|
|
it('updates and forwards input', async () => {
|
|
const update = vi.fn(async () => sampleResult)
|
|
const { deps } = makeDeps({ updateUserPersonalEntitlement: update })
|
|
const out = await updateUserEntitlement(deps, {
|
|
adminUserId: 'admin',
|
|
targetUserId: 'u-1',
|
|
entitlementId: 'ent-1',
|
|
bytes: 5000,
|
|
expiresAt: undefined,
|
|
note: 'bumped',
|
|
})
|
|
expect(out).toEqual({ ok: true, result: sampleResult })
|
|
expect(update).toHaveBeenCalledWith({
|
|
adminUserId: 'admin',
|
|
targetUserId: 'u-1',
|
|
entitlementId: 'ent-1',
|
|
bytes: 5000,
|
|
expiresAt: undefined,
|
|
note: 'bumped',
|
|
})
|
|
})
|
|
|
|
it('threads the repo failure outward', async () => {
|
|
const { deps } = makeDeps({ updateUserPersonalEntitlement: async () => failure })
|
|
const out = await updateUserEntitlement(deps, {
|
|
adminUserId: 'admin',
|
|
targetUserId: 'x',
|
|
entitlementId: 'ent-x',
|
|
})
|
|
expect(out).toEqual({ ok: false, failure })
|
|
})
|
|
})
|
|
|
|
describe('revokeUserEntitlement', () => {
|
|
it('revokes and forwards input', async () => {
|
|
const revoke = vi.fn(async () => sampleResult)
|
|
const { deps } = makeDeps({ revokeUserPersonalEntitlement: revoke })
|
|
const out = await revokeUserEntitlement(deps, {
|
|
adminUserId: 'admin',
|
|
targetUserId: 'u-1',
|
|
entitlementId: 'ent-1',
|
|
})
|
|
expect(out).toEqual({ ok: true, result: sampleResult })
|
|
expect(revoke).toHaveBeenCalledWith({ adminUserId: 'admin', targetUserId: 'u-1', entitlementId: 'ent-1' })
|
|
})
|
|
|
|
it('threads the repo failure outward', async () => {
|
|
const { deps } = makeDeps({ revokeUserPersonalEntitlement: async () => failure })
|
|
const out = await revokeUserEntitlement(deps, {
|
|
adminUserId: 'admin',
|
|
targetUserId: 'x',
|
|
entitlementId: 'ent-x',
|
|
})
|
|
expect(out).toEqual({ ok: false, failure })
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('avatar (self)', () => {
|
|
const AVATAR_PREFIX = '_system/avatars'
|
|
// platform is an opaque request-bound capability here — the usecase only
|
|
// forwards it to the gateway, so a sentinel is enough to assert pass-through.
|
|
const platform = { tag: 'platform' } as unknown as Platform
|
|
const sampleFile = new File([new Uint8Array(8)], 'a.png', { type: 'image/png' })
|
|
|
|
function makeAvatarDeps(image: Partial<ImageUpload> = {}) {
|
|
const setAvatar = vi.fn(async () => {})
|
|
const uploadPublicImage = vi.fn(async (): Promise<ImageUploadResult> => ({ ok: true, url: 'https://cdn/a.png' }))
|
|
const deletePublicImageVariants = vi.fn(async () => {})
|
|
const deps: AvatarDeps = {
|
|
imageUpload: { uploadPublicImage, deletePublicImageVariants, ...image } as ImageUpload,
|
|
profiles: { setAvatar } as unknown as ProfileRepo,
|
|
}
|
|
return { deps, setAvatar, uploadPublicImage, deletePublicImageVariants }
|
|
}
|
|
|
|
beforeEach(() => vi.clearAllMocks())
|
|
|
|
describe('updateAvatar', () => {
|
|
it('uploads, persists the url via setAvatar, and returns it', async () => {
|
|
const uploadPublicImage = vi.fn(
|
|
async (): Promise<ImageUploadResult> => ({ ok: true, url: 'https://cdn/_system/avatars/u1.png' }),
|
|
)
|
|
const { deps, setAvatar } = makeAvatarDeps({ uploadPublicImage })
|
|
|
|
const out = await updateAvatar(deps, { platform, userId: 'u1', file: sampleFile })
|
|
|
|
expect(out).toEqual({ ok: true, url: 'https://cdn/_system/avatars/u1.png' })
|
|
expect(uploadPublicImage).toHaveBeenCalledWith(platform, AVATAR_PREFIX, 'u1', sampleFile)
|
|
expect(setAvatar).toHaveBeenCalledWith('u1', 'https://cdn/_system/avatars/u1.png')
|
|
})
|
|
|
|
// The gateway owns which status a rejection carries (400 bad mime, 413 too
|
|
// large, 503 no public storage); the usecase surfaces it verbatim.
|
|
it.each([
|
|
{ ok: false, status: 400, error: 'unsupported mime' },
|
|
{ ok: false, status: 413, error: 'too large' },
|
|
{ ok: false, status: 503, error: 'no public storage' },
|
|
] satisfies ImageUploadResult[])('surfaces gateway failure ($status) and does not persist', async (failure) => {
|
|
const uploadPublicImage = vi.fn(async (): Promise<ImageUploadResult> => failure)
|
|
const { deps, setAvatar } = makeAvatarDeps({ uploadPublicImage })
|
|
|
|
const out = await updateAvatar(deps, { platform, userId: 'u1', file: sampleFile })
|
|
|
|
expect(out).toEqual(failure)
|
|
expect(setAvatar).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('removeAvatar', () => {
|
|
it('clears the avatar in DB first, then best-effort removes storage variants', async () => {
|
|
const calls: string[] = []
|
|
const setAvatar = vi.fn(async () => {
|
|
calls.push('setAvatar')
|
|
})
|
|
const deletePublicImageVariants = vi.fn(async () => {
|
|
calls.push('deleteVariants')
|
|
})
|
|
const { deps } = makeAvatarDeps({ deletePublicImageVariants })
|
|
deps.profiles = { setAvatar } as unknown as ProfileRepo
|
|
|
|
await removeAvatar(deps, { platform, userId: 'u1' })
|
|
|
|
expect(setAvatar).toHaveBeenCalledWith('u1', null)
|
|
expect(deletePublicImageVariants).toHaveBeenCalledWith(platform, AVATAR_PREFIX, 'u1')
|
|
expect(calls).toEqual(['setAvatar', 'deleteVariants'])
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('public profile', () => {
|
|
const samplePublicUser: PublicUser = { username: 'bob', name: 'Bob', image: null }
|
|
const withUser = (user: PublicUser | null) => ({
|
|
profiles: { getUserByUsername: async () => user, setAvatar: async () => {} } as ProfileRepo,
|
|
})
|
|
|
|
it('returns the public user', async () => {
|
|
expect(await getPublicProfile(withUser(samplePublicUser), 'bob')).toEqual(samplePublicUser)
|
|
})
|
|
|
|
it('returns null when the user does not exist', async () => {
|
|
expect(await getPublicProfile(withUser(null), 'ghost')).toBeNull()
|
|
})
|
|
|
|
it('queries by the given username', async () => {
|
|
const getUserByUsername = vi.fn(async () => samplePublicUser)
|
|
await getPublicProfile({ profiles: { getUserByUsername, setAvatar: async () => {} } as ProfileRepo }, 'alice')
|
|
expect(getUserByUsername).toHaveBeenCalledWith('alice')
|
|
})
|
|
})
|