292 lines
10 KiB
TypeScript
292 lines
10 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
describe('user avatar storage and controller', () => {
|
|
let db: any = null
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules()
|
|
vi.stubEnv('AUTH_JWT_SECRET', 'test-secret')
|
|
const { DatabaseSync } = await import('node:sqlite')
|
|
db = new DatabaseSync(':memory:')
|
|
vi.doMock('../../packages/server/src/db/index', () => ({
|
|
getDb: () => db,
|
|
getStoragePath: () => ':memory:',
|
|
}))
|
|
})
|
|
|
|
afterEach(() => {
|
|
db?.close()
|
|
db = null
|
|
vi.doUnmock('../../packages/server/src/db/index')
|
|
vi.doUnmock('../../packages/server/src/services/hermes/hermes-profile')
|
|
vi.unstubAllEnvs()
|
|
vi.resetModules()
|
|
})
|
|
|
|
async function initUsers() {
|
|
const schemas = await import('../../packages/server/src/db/hermes/schemas')
|
|
schemas.initAllHermesTables()
|
|
return {
|
|
schemas,
|
|
users: await import('../../packages/server/src/db/hermes/users-store'),
|
|
ctrl: await import('../../packages/server/src/controllers/auth'),
|
|
}
|
|
}
|
|
|
|
function makeControllerCtx(user: any | null | undefined, body: any = {}) {
|
|
return {
|
|
state: user ? { user } : {},
|
|
request: { body },
|
|
status: 200,
|
|
body: null,
|
|
} as any
|
|
}
|
|
|
|
describe('users-store avatar helpers', () => {
|
|
it('returns an empty string for a brand new user', async () => {
|
|
const { users } = await initUsers()
|
|
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
|
|
|
|
expect(users.getUserAvatar(admin.id)).toBe('')
|
|
})
|
|
|
|
it('round-trips avatar JSON through set/getUserAvatar', async () => {
|
|
const { users } = await initUsers()
|
|
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
|
|
const payload = JSON.stringify({ type: 'image', dataUrl: 'data:image/png;base64,AAA' })
|
|
|
|
const ok = users.setUserAvatar(admin.id, payload)
|
|
expect(ok).toBe(true)
|
|
expect(users.getUserAvatar(admin.id)).toBe(payload)
|
|
})
|
|
|
|
it('setUserAvatar returns false when the user does not exist', async () => {
|
|
const { users } = await initUsers()
|
|
// create a user so the table exists, but use a clearly non-existent id
|
|
users.bootstrapDefaultSuperAdmin('admin', '123456')
|
|
|
|
expect(users.setUserAvatar(9999, '{"type":"default"}')).toBe(false)
|
|
expect(users.getUserAvatar(9999)).toBe('')
|
|
})
|
|
|
|
it('setUserAvatar returns false when given a non-numeric userId', async () => {
|
|
const { users } = await initUsers()
|
|
users.bootstrapDefaultSuperAdmin('admin', '123456')
|
|
|
|
expect(users.setUserAvatar('not-a-number', '{"type":"default"}')).toBe(false)
|
|
expect(users.setUserAvatar(-1, '{"type":"default"}')).toBe(false)
|
|
expect(users.setUserAvatar(0, '{"type":"default"}')).toBe(false)
|
|
})
|
|
|
|
it('persists the avatar in the users table', async () => {
|
|
const { users, schemas } = await initUsers()
|
|
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
|
|
const payload = JSON.stringify({ type: 'default' })
|
|
|
|
users.setUserAvatar(admin.id, payload)
|
|
|
|
const row = db
|
|
.prepare(`SELECT avatar FROM ${schemas.USERS_TABLE} WHERE id = ?`)
|
|
.get(admin.id) as { avatar?: string }
|
|
expect(row?.avatar).toBe(payload)
|
|
})
|
|
})
|
|
|
|
describe('getMyAvatar controller', () => {
|
|
it('returns 401 when the request is unauthenticated', async () => {
|
|
const { ctrl } = await initUsers()
|
|
const ctx = makeControllerCtx(null)
|
|
|
|
await ctrl.getMyAvatar(ctx)
|
|
|
|
expect(ctx.status).toBe(401)
|
|
expect(ctx.body).toEqual({ error: 'Unauthorized' })
|
|
})
|
|
|
|
it('returns the avatar string for the authenticated user', async () => {
|
|
const { users, ctrl } = await initUsers()
|
|
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
|
|
const payload = JSON.stringify({ type: 'image', dataUrl: 'data:image/png;base64,HI==' })
|
|
users.setUserAvatar(admin.id, payload)
|
|
|
|
const ctx = makeControllerCtx({ id: admin.id, username: 'admin', role: 'super_admin' })
|
|
await ctrl.getMyAvatar(ctx)
|
|
|
|
expect(ctx.status).toBe(200)
|
|
expect(ctx.body).toEqual({ avatar: payload })
|
|
})
|
|
|
|
it('returns an empty avatar string for an authenticated user without an avatar', async () => {
|
|
const { users, ctrl } = await initUsers()
|
|
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
|
|
|
|
const ctx = makeControllerCtx({ id: admin.id, username: 'admin', role: 'super_admin' })
|
|
await ctrl.getMyAvatar(ctx)
|
|
|
|
expect(ctx.status).toBe(200)
|
|
expect(ctx.body).toEqual({ avatar: '' })
|
|
})
|
|
})
|
|
|
|
describe('updateMyAvatar controller', () => {
|
|
it('persists a default avatar payload sent as a direct object', async () => {
|
|
const { users, ctrl } = await initUsers()
|
|
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
|
|
|
|
const ctx = makeControllerCtx(
|
|
{ id: admin.id, username: 'admin', role: 'super_admin' },
|
|
{ type: 'default' },
|
|
)
|
|
await ctrl.updateMyAvatar(ctx)
|
|
|
|
expect(ctx.status).toBe(200)
|
|
expect(ctx.body.success).toBe(true)
|
|
const stored = users.getUserAvatar(admin.id)
|
|
expect(JSON.parse(stored)).toEqual({ type: 'default' })
|
|
})
|
|
|
|
it('persists an image avatar payload sent as a direct object', async () => {
|
|
const { users, ctrl } = await initUsers()
|
|
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
|
|
const dataUrl = 'data:image/png;base64,iVBORw0KGgo='
|
|
|
|
const ctx = makeControllerCtx(
|
|
{ id: admin.id, username: 'admin', role: 'super_admin' },
|
|
{ type: 'image', dataUrl },
|
|
)
|
|
await ctrl.updateMyAvatar(ctx)
|
|
|
|
expect(ctx.status).toBe(200)
|
|
expect(ctx.body.success).toBe(true)
|
|
const stored = users.getUserAvatar(admin.id)
|
|
expect(JSON.parse(stored)).toEqual({ type: 'image', dataUrl })
|
|
})
|
|
|
|
it('persists a JSON-string avatar passed in the avatar field', async () => {
|
|
const { users, ctrl } = await initUsers()
|
|
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
|
|
const serialized = JSON.stringify({ type: 'default', seed: 'abc' })
|
|
|
|
const ctx = makeControllerCtx(
|
|
{ id: admin.id, username: 'admin', role: 'super_admin' },
|
|
{ avatar: serialized },
|
|
)
|
|
await ctrl.updateMyAvatar(ctx)
|
|
|
|
expect(ctx.status).toBe(200)
|
|
expect(ctx.body.avatar).toBe(serialized)
|
|
expect(users.getUserAvatar(admin.id)).toBe(serialized)
|
|
})
|
|
|
|
it('rejects an unknown type with HTTP 400', async () => {
|
|
const { users, ctrl } = await initUsers()
|
|
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
|
|
|
|
const ctx = makeControllerCtx(
|
|
{ id: admin.id, username: 'admin', role: 'super_admin' },
|
|
{ type: 'emoji', dataUrl: '🦊' },
|
|
)
|
|
await ctrl.updateMyAvatar(ctx)
|
|
|
|
expect(ctx.status).toBe(400)
|
|
expect(ctx.body.error).toMatch(/image.*default/)
|
|
expect(users.getUserAvatar(admin.id)).toBe('')
|
|
})
|
|
|
|
it('rejects an image payload whose dataUrl exceeds 500KB with HTTP 400', async () => {
|
|
const { users, ctrl } = await initUsers()
|
|
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
|
|
const oversized = 'data:image/png;base64,' + 'A'.repeat(500 * 1024 + 16)
|
|
|
|
const ctx = makeControllerCtx(
|
|
{ id: admin.id, username: 'admin', role: 'super_admin' },
|
|
{ type: 'image', dataUrl: oversized },
|
|
)
|
|
await ctrl.updateMyAvatar(ctx)
|
|
|
|
expect(ctx.status).toBe(400)
|
|
expect(ctx.body.error).toMatch(/too large|bytes/)
|
|
expect(users.getUserAvatar(admin.id)).toBe('')
|
|
})
|
|
|
|
it('rejects an image payload with no dataUrl with HTTP 400', async () => {
|
|
const { users, ctrl } = await initUsers()
|
|
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
|
|
|
|
const ctx = makeControllerCtx(
|
|
{ id: admin.id, username: 'admin', role: 'super_admin' },
|
|
{ type: 'image' },
|
|
)
|
|
await ctrl.updateMyAvatar(ctx)
|
|
|
|
expect(ctx.status).toBe(400)
|
|
expect(ctx.body.error).toMatch(/dataUrl/)
|
|
expect(users.getUserAvatar(admin.id)).toBe('')
|
|
})
|
|
|
|
it('rejects an image payload whose dataUrl is not a data URL with HTTP 400', async () => {
|
|
const { users, ctrl } = await initUsers()
|
|
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
|
|
|
|
const ctx = makeControllerCtx(
|
|
{ id: admin.id, username: 'admin', role: 'super_admin' },
|
|
{ type: 'image', dataUrl: 'https://example.com/avatar.png' },
|
|
)
|
|
await ctrl.updateMyAvatar(ctx)
|
|
|
|
expect(ctx.status).toBe(400)
|
|
expect(ctx.body.error).toMatch(/dataUrl/)
|
|
})
|
|
|
|
it('rejects a non-object body with HTTP 400', async () => {
|
|
const { ctrl } = await initUsers()
|
|
|
|
const ctx = makeControllerCtx(
|
|
{ id: 1, username: 'admin', role: 'super_admin' },
|
|
'just-a-string',
|
|
)
|
|
await ctrl.updateMyAvatar(ctx)
|
|
|
|
expect(ctx.status).toBe(400)
|
|
// A bare string is not valid JSON, so the controller rejects it via the JSON.parse path.
|
|
expect(ctx.body.error).toMatch(/not valid JSON|Invalid avatar payload|too large/)
|
|
})
|
|
|
|
it('rejects a malformed JSON string in the avatar field with HTTP 400', async () => {
|
|
const { ctrl } = await initUsers()
|
|
|
|
const ctx = makeControllerCtx(
|
|
{ id: 1, username: 'admin', role: 'super_admin' },
|
|
{ avatar: '{not valid json' },
|
|
)
|
|
await ctrl.updateMyAvatar(ctx)
|
|
|
|
expect(ctx.status).toBe(400)
|
|
expect(ctx.body.error).toMatch(/not valid JSON|Invalid/)
|
|
})
|
|
|
|
it('rejects a non-string seed with HTTP 400', async () => {
|
|
const { ctrl } = await initUsers()
|
|
|
|
const ctx = makeControllerCtx(
|
|
{ id: 1, username: 'admin', role: 'super_admin' },
|
|
{ type: 'default', seed: 123 },
|
|
)
|
|
await ctrl.updateMyAvatar(ctx)
|
|
|
|
expect(ctx.status).toBe(400)
|
|
expect(ctx.body.error).toMatch(/seed/)
|
|
})
|
|
|
|
it('returns 401 when the request is unauthenticated', async () => {
|
|
const { ctrl } = await initUsers()
|
|
const ctx = makeControllerCtx(null, { type: 'default' })
|
|
|
|
await ctrl.updateMyAvatar(ctx)
|
|
|
|
expect(ctx.status).toBe(401)
|
|
expect(ctx.body).toEqual({ error: 'Unauthorized' })
|
|
})
|
|
})
|
|
})
|