test(cabana): cover My account profile, password and avatar

- account_internal_test: metadata subtitle reader and writer (NULL, blank,
  null, [], invalid JSON, non-objects, key preservation, NULL on the last
  key), the attachOne contract, avatar cap, unique-violation mapping,
  English fallbacks, and 401 for non-backend principals on all three routes
- account_test (Postgres): profile payload incl. copied WinterCMS metadata
  and avatar rows, trimmed profile saves and metadata keys, the forbidden-key
  allow-list with unchanged rows, email rules, password rules, the failed
  attempt log, Bearer and cookie session effects, CSRF, avatar upload,
  refusals (SVG, HTML as PNG, size, part shape, 413, no bucket) with no row
  or blob left, replace and remove with blob cleanup
- lagoon: TestBackendAdminMetadataColumn covers type, Winter row round trip,
  rollback of only the column and re-migrate
- SPA: AccountView, the user menu account item and avatar image,
  setCurrentUser and profileInitials; lang fixture gains myaccount keys
This commit is contained in:
2026-10-09 17:59:27 +02:00
parent 4f1cda33d7
commit 89611eaaf9
7 changed files with 1733 additions and 64 deletions

View File

@@ -282,6 +282,81 @@
"many": "Odłączono :count rekordów",
"other": "Odłączono :count rekordu"
},
"backend::lang.myaccount.avatar": {
"other": "Awatar"
},
"backend::lang.myaccount.avatar_hint": {
"other": "JPG, PNG, GIF lub WebP, maksymalnie 2 MB."
},
"backend::lang.myaccount.avatar_remove": {
"other": "Usuń zdjęcie"
},
"backend::lang.myaccount.avatar_remove_confirm": {
"other": "Usunąć awatar?"
},
"backend::lang.myaccount.avatar_removed": {
"other": "Usunięto awatar"
},
"backend::lang.myaccount.avatar_saved": {
"other": "Zapisano awatar"
},
"backend::lang.myaccount.avatar_upload": {
"other": "Wgraj zdjęcie"
},
"backend::lang.myaccount.current_password": {
"other": "Obecne hasło"
},
"backend::lang.myaccount.current_password_invalid": {
"other": "Obecne hasło jest nieprawidłowe."
},
"backend::lang.myaccount.email": {
"other": "E-mail"
},
"backend::lang.myaccount.field_not_editable": {
"other": "Tego pola nie można zmienić w ustawieniach konta."
},
"backend::lang.myaccount.first_name": {
"other": "Imię"
},
"backend::lang.myaccount.last_name": {
"other": "Nazwisko"
},
"backend::lang.myaccount.login": {
"other": "Login"
},
"backend::lang.myaccount.menu_description": {
"other": "Zmień imię, nazwisko, e-mail, podtytuł i hasło."
},
"backend::lang.myaccount.menu_label": {
"other": "Moje konto"
},
"backend::lang.myaccount.password": {
"other": "Nowe hasło"
},
"backend::lang.myaccount.password_comment": {
"other": "Pozostaw puste, aby zachować obecne hasło."
},
"backend::lang.myaccount.password_confirmation": {
"other": "Powtórz nowe hasło"
},
"backend::lang.myaccount.password_section": {
"other": "Zmiana hasła"
},
"backend::lang.myaccount.profile": {
"other": "Profil"
},
"backend::lang.myaccount.saved": {
"other": "Zapisano konto"
},
"backend::lang.myaccount.subtitle": {
"other": "Podtytuł"
},
"backend::lang.myaccount.subtitle_comment": {
"other": "Krótki opis pod Twoim imieniem, na przykład rola lub stanowisko."
},
"backend::lang.myaccount.subtitle_unavailable": {
"other": "Nie można zapisać podtytułu, ponieważ zapisane metadane profilu nie są obiektem JSON."
},
"backend::lang.nav.breadcrumbs": {
"other": "Ścieżka nawigacji"
},

View File

@@ -1,10 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import type { AdminProfile } from '../../src/api/types'
import UserMenu from '../../src/components/shell/UserMenu.vue'
import { currentUser } from '../../src/state/useAuth'
import { API, profile, requestsTo, resetState, routerAt, signIn, wait } from '../helpers'
async function mountMenu(user: Partial<typeof profile.data> | null) {
async function mountMenu(user: Partial<AdminProfile> | null) {
let calls: Request[] = []
if (user) {
calls = await signIn({
@@ -68,6 +69,48 @@ describe('user menu', () => {
expect(wrapper.find('[data-user-initials]').text()).toBe('')
})
it('opens My account from the menu', async () => {
const { wrapper, router } = await mountMenu({})
const content = await open(wrapper)
const items = [...content.querySelectorAll<HTMLElement>('[role="menuitem"]')].map((item) => item.dataset.action)
expect(items).toEqual(['account', 'logout'])
const item = content.querySelector<HTMLElement>('[data-action="account"]')!
expect(item.getAttribute('role')).toBe('menuitem')
expect(item.textContent?.trim()).toBe('Moje konto')
item.click()
await flushPromises()
await wait(0)
await flushPromises()
expect(router.currentRoute.value.name).toBe('account')
expect(router.currentRoute.value.fullPath).toBe('/account')
expect(currentUser.value).not.toBeNull()
})
it('shows the avatar thumbnail in place of the initials in the trigger and the menu', async () => {
const avatar = { url: '/storage/uploads/a/b/c/abc.png', thumb_url: '/storage/uploads/a/b/c/thumb_1_250_250_0_0_crop.png' }
const { wrapper } = await mountMenu({ avatar })
const image = wrapper.find('[data-user-avatar]')
expect(image.attributes('src')).toBe(avatar.thumb_url)
expect(image.attributes('alt')).toBe('')
expect(image.classes()).toEqual(expect.arrayContaining(['rounded-full', 'object-cover', 'size-[34px]']))
expect(wrapper.find('[data-user-initials]').exists()).toBe(false)
const content = await open(wrapper)
const header = content.querySelector<HTMLImageElement>('img[data-user-avatar]')!
expect(header.getAttribute('src')).toBe(avatar.thumb_url)
expect(header.classList.contains('size-10')).toBe(true)
})
it('falls back to the full image without a thumbnail, and to the initials without an avatar', async () => {
const { wrapper } = await mountMenu({ avatar: { url: '/storage/uploads/a/b/c/abc.png' } })
expect(wrapper.find('[data-user-avatar]').attributes('src')).toBe('/storage/uploads/a/b/c/abc.png')
wrapper.unmount()
resetState()
const plain = await mountMenu({})
expect(plain.wrapper.find('[data-user-avatar]').exists()).toBe(false)
expect(plain.wrapper.find('[data-user-initials]').text()).toBe('DD')
})
it('opens a menu with the e-mail and logs out to the login route', async () => {
const { wrapper, router, calls } = await mountMenu({})
const content = await open(wrapper)

View File

@@ -1,5 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { clearUser, currentUser, login, logout, me, useAuth } from '../../src/state/useAuth'
import {
clearUser,
currentUser,
login,
logout,
me,
profileInitials,
setCurrentUser,
useAuth,
} from '../../src/state/useAuth'
import { navigation, setNavigation } from '../../src/state/useNavigation'
import { setSettings, settings } from '../../src/state/useSettings'
import { navigationFixture, settingsFixture } from '../fixtures/typed'
@@ -139,3 +148,24 @@ describe('logout (T-10-23)', () => {
expect(auth.clearUser).toBe(clearUser)
})
})
describe('setCurrentUser and profileInitials', () => {
it('replaces the signed-in profile, as My account does after a save', async () => {
mockApi({ [`GET ${API}/auth/me`]: { body: profile } })
await me()
const next = { ...profile.data, first_name: 'Grace', subtitle: 'Admiral', avatar: { url: '/a.png' } }
setCurrentUser(next)
expect(currentUser.value).toEqual(next)
expect(useAuth().user.value?.subtitle).toBe('Admiral')
expect(useAuth().setCurrentUser).toBe(setCurrentUser)
clearUser()
expect(currentUser.value).toBeNull()
})
it('takes the name initials, else the login letters', () => {
expect(profileInitials(null)).toBe('')
expect(profileInitials({ first_name: 'grace', last_name: 'hopper', login: 'gh' })).toBe('GH')
expect(profileInitials({ first_name: ' ', last_name: 'Hopper', login: 'gh' })).toBe('H')
expect(profileInitials({ first_name: '', last_name: '', login: 'root' })).toBe('RO')
})
})

View File

@@ -0,0 +1,237 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
import AccountView from '../../src/views/AccountView.vue'
import { currentUser } from '../../src/state/useAuth'
import { toasts } from '../../src/state/useToasts'
import { API, mountApp, profile, requestsTo, resetState, type Route } from '../helpers'
const ACCOUNT = `${API}/account`
const AVATAR = `${API}/account/avatar`
const signedIn = {
...profile.data,
first_name: 'Dana',
last_name: 'Dev',
subtitle: 'Editor',
}
const avatar = { url: '/storage/uploads/abc/def/ghi/abcdefghi.png', thumb_url: '/storage/uploads/abc/def/ghi/thumb_9_250_250_0_0_crop.png' }
const envelope = (data: unknown) => ({ body: { data, meta: {} } })
const invalid = (details: Record<string, string[]>) => ({
status: 422,
body: { error: { code: 'validation_failed', message: 'Validation failed', details } },
})
async function openAccount(user: Record<string, unknown> = signedIn, routes: Record<string, Route> = {}) {
return mountApp('/account', { [`GET ${API}/auth/me`]: envelope(user), ...routes }, { attach: true })
}
function input(wrapper: VueWrapper, name: string) {
return wrapper.find<HTMLInputElement>(`#account-${name}`)
}
async function typeInto(wrapper: VueWrapper, name: string, value: string): Promise<void> {
await input(wrapper, name).setValue(value)
}
async function save(wrapper: VueWrapper): Promise<void> {
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
}
async function bodyOf(request: Request): Promise<Record<string, unknown>> {
return (await request.clone().json()) as Record<string, unknown>
}
async function pickFile(wrapper: VueWrapper, file: File): Promise<void> {
const element = wrapper.find<HTMLInputElement>('[data-account-avatar-input]')
Object.defineProperty(element.element, 'files', { value: [file], configurable: true })
await element.trigger('change')
await flushPromises()
}
const dialog = () => document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('My account', () => {
it('renders the signed-in profile with the login read-only', async () => {
const { wrapper } = await openAccount()
expect(wrapper.findComponent(AccountView).exists()).toBe(true)
expect(wrapper.find('h1').text()).toBe('Moje konto')
const login = input(wrapper, 'login')
expect(login.element.value).toBe('dev')
expect(login.attributes('readonly')).toBeDefined()
expect(input(wrapper, 'first_name').element.value).toBe('Dana')
expect(input(wrapper, 'last_name').element.value).toBe('Dev')
expect(input(wrapper, 'email').element.value).toBe('dev@example.test')
expect(input(wrapper, 'email').attributes('type')).toBe('email')
expect(input(wrapper, 'subtitle').element.value).toBe('Editor')
expect(input(wrapper, 'subtitle').attributes('aria-describedby')).toBe('account-subtitle-comment')
expect(input(wrapper, 'current_password').attributes('autocomplete')).toBe('current-password')
expect(input(wrapper, 'password').attributes('autocomplete')).toBe('new-password')
expect(input(wrapper, 'password_confirmation').attributes('autocomplete')).toBe('new-password')
expect(input(wrapper, 'password').element.value).toBe('')
expect(wrapper.find('[data-account-avatar-initials]').text()).toBe('DD')
expect(wrapper.find('[data-action="avatar-remove"]').exists()).toBe(false)
})
it('sends only the profile keys when no new password is typed', async () => {
const { wrapper, calls } = await openAccount(signedIn, {
[`PUT ${ACCOUNT}`]: envelope({ ...signedIn, first_name: 'Grace', subtitle: 'Admiral' }),
})
await typeInto(wrapper, 'first_name', 'Grace')
await typeInto(wrapper, 'subtitle', 'Admiral')
await typeInto(wrapper, 'current_password', 'typed-but-unused')
await save(wrapper)
const [request] = requestsTo(calls, 'PUT', ACCOUNT)
expect(request!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(await bodyOf(request!)).toEqual({
first_name: 'Grace',
last_name: 'Dev',
email: 'dev@example.test',
subtitle: 'Admiral',
})
expect(currentUser.value?.first_name).toBe('Grace')
expect(currentUser.value?.subtitle).toBe('Admiral')
expect(toasts.value[0]).toMatchObject({ text: 'Zapisano konto', tone: 'success' })
})
it('sends the password keys with a new password, updates the top bar and clears the inputs', async () => {
const { wrapper, calls } = await openAccount(signedIn, {
[`PUT ${ACCOUNT}`]: envelope({ ...signedIn, first_name: 'Grace', last_name: 'Hopper' }),
})
await typeInto(wrapper, 'first_name', 'Grace')
await typeInto(wrapper, 'last_name', 'Hopper')
await typeInto(wrapper, 'current_password', 'old-secret')
await typeInto(wrapper, 'password', 'new-secret')
await typeInto(wrapper, 'password_confirmation', 'new-secret')
await save(wrapper)
const [request] = requestsTo(calls, 'PUT', ACCOUNT)
expect(await bodyOf(request!)).toEqual({
first_name: 'Grace',
last_name: 'Hopper',
email: 'dev@example.test',
subtitle: 'Editor',
current_password: 'old-secret',
password: 'new-secret',
password_confirmation: 'new-secret',
})
expect(input(wrapper, 'current_password').element.value).toBe('')
expect(input(wrapper, 'password').element.value).toBe('')
expect(input(wrapper, 'password_confirmation').element.value).toBe('')
expect(wrapper.find('[data-user-identity]').text()).toContain('Grace Hopper')
expect(wrapper.find('[data-user-initials]').text()).toBe('GH')
})
it('shows 422 errors on their fields, focuses the first and clears an error on input', async () => {
const { wrapper } = await openAccount(signedIn, {
[`PUT ${ACCOUNT}`]: invalid({
current_password: ['Obecne hasło jest nieprawidłowe.'],
email: ['The email has already been taken.'],
role_id: ['Tego pola nie można zmienić w ustawieniach konta.'],
}),
})
await typeInto(wrapper, 'password', 'new-secret')
await save(wrapper)
expect(wrapper.find('[data-error-banner]').exists()).toBe(true)
expect(wrapper.find('[data-error-banner]').text()).toContain('Tego pola nie można zmienić')
const email = input(wrapper, 'email')
expect(email.attributes('aria-invalid')).toBe('true')
expect(email.attributes('aria-describedby')).toBe('account-email-error')
expect(wrapper.find('#account-email-error').text()).toBe('The email has already been taken.')
expect(wrapper.find('#account-current_password-error').text()).toBe('Obecne hasło jest nieprawidłowe.')
expect(document.activeElement).toBe(email.element)
expect(currentUser.value?.first_name).toBe('Dana')
await typeInto(wrapper, 'email', 'other@example.test')
expect(wrapper.find('#account-email-error').exists()).toBe(false)
expect(wrapper.find('#account-current_password-error').exists()).toBe(true)
})
it('toasts the generic error on any other failure', async () => {
const { wrapper } = await openAccount(signedIn, {
[`PUT ${ACCOUNT}`]: { status: 500, body: { error: { code: 'error', message: 'Server error', details: {} } } },
})
await save(wrapper)
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' })
expect(wrapper.find('[data-error-banner]').exists()).toBe(false)
})
it('uploads the avatar as one file_data part and shows it in the top bar', async () => {
const posted: string[] = []
const { wrapper, calls } = await openAccount(signedIn, {
[`POST ${AVATAR}`]: async (request) => {
const form = await request.formData()
const file = form.get('file_data') as File
posted.push([...form.keys()].join(','), file.name, await file.text())
return envelope({ ...signedIn, avatar })
},
})
await pickFile(wrapper, new File(['png-bytes'], 'me.png', { type: 'image/png' }))
const [request] = requestsTo(calls, 'POST', AVATAR)
expect(request!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(request!.headers.get('Content-Type')).toMatch(/^multipart\/form-data; boundary=/)
expect(posted).toEqual(['file_data', 'me.png', 'png-bytes'])
expect(currentUser.value?.avatar).toEqual(avatar)
expect(wrapper.find('[data-account-avatar-image]').attributes('src')).toBe(avatar.thumb_url)
expect(wrapper.find('[data-user-avatar]').attributes('src')).toBe(avatar.thumb_url)
expect(wrapper.find('[data-user-initials]').exists()).toBe(false)
expect(toasts.value[0]).toMatchObject({ text: 'Zapisano awatar', tone: 'success' })
expect(wrapper.find('[data-account-avatar-input]').attributes('accept')).toBe('image/jpeg,image/png,image/gif,image/webp')
})
it('shows the avatar 422 detail and the hint on 413', async () => {
let status = 422
const { wrapper } = await openAccount(signedIn, {
[`POST ${AVATAR}`]: () =>
status === 422
? invalid({ avatar: ['The avatar must be an image.'] })
: { status: 413, body: { error: { code: 'payload_too_large', message: 'Payload too large', details: {} } } },
})
await pickFile(wrapper, new File(['<svg/>'], 'logo.svg', { type: 'image/svg+xml' }))
expect(wrapper.find('[data-avatar-error]').text()).toBe('The avatar must be an image.')
expect(currentUser.value?.avatar).toBeUndefined()
status = 413
await pickFile(wrapper, new File(['x'], 'big.png', { type: 'image/png' }))
expect(wrapper.find('[data-avatar-error]').text()).toBe('JPG, PNG, GIF lub WebP, maksymalnie 2 MB.')
})
it('removes the avatar after a confirmation', async () => {
const { wrapper, calls } = await openAccount({ ...signedIn, avatar }, {
[`DELETE ${AVATAR}`]: envelope(signedIn),
})
expect(wrapper.find('[data-account-avatar-image]').attributes('src')).toBe(avatar.thumb_url)
await wrapper.find('[data-action="avatar-remove"]').trigger('click')
await flushPromises()
expect(dialog()!.textContent).toContain('Usunąć awatar?')
dialog()!.querySelector<HTMLButtonElement>('[data-action="cancel"]')!.click()
await flushPromises()
expect(requestsTo(calls, 'DELETE', AVATAR)).toHaveLength(0)
await wrapper.find('[data-action="avatar-remove"]').trigger('click')
await flushPromises()
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
await flushPromises()
expect(requestsTo(calls, 'DELETE', AVATAR)).toHaveLength(1)
expect(currentUser.value?.avatar).toBeUndefined()
expect(wrapper.find('[data-account-avatar-initials]').text()).toBe('DD')
expect(wrapper.find('[data-action="avatar-remove"]').exists()).toBe(false)
expect(toasts.value[0]).toMatchObject({ text: 'Usunięto awatar', tone: 'success' })
})
it('names the page in the breadcrumbs and the document title', async () => {
const { wrapper } = await openAccount()
expect(wrapper.find('[data-crumb]').text()).toBe('Moje konto')
expect(document.title).toBe('Moje konto | SummerCMS')
})
})

View File

@@ -0,0 +1,246 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"github.com/jackc/pgx/v5/pgconn"
)
func strPtr(s string) *string { return &s }
// TestMetadataSubtitleHelpers covers the lenient reader and the
// key-preserving writer of Winter's jsonable backend_users.metadata.
func TestMetadataSubtitleHelpers(t *testing.T) {
t.Run("read", func(t *testing.T) {
for _, tc := range []struct {
name string
raw *string
want string
}{
{"nil", nil, ""},
{"blank", strPtr(""), ""},
{"spaces", strPtr(" "), ""},
{"null", strPtr("null"), ""},
{"php empty array", strPtr("[]"), ""},
{"subtitle", strPtr(`{"subtitle":"Editor"}`), "Editor"},
{"padded subtitle", strPtr(` {"subtitle":"Editor","x":1} `), "Editor"},
{"number", strPtr(`{"subtitle":5}`), ""},
{"null value", strPtr(`{"subtitle":null}`), ""},
{"missing key", strPtr(`{"x":"y"}`), ""},
{"invalid json", strPtr(`{"subtitle":`), ""},
{"string", strPtr(`"text"`), ""},
{"array", strPtr(`[1]`), ""},
} {
if got := metadataString(tc.raw, metadataSubtitle); got != tc.want {
t.Errorf("%s: metadataString = %q, want %q", tc.name, got, tc.want)
}
if got := (BackendUser{Metadata: tc.raw}).Subtitle(); got != tc.want {
t.Errorf("%s: Subtitle = %q, want %q", tc.name, got, tc.want)
}
}
})
t.Run("write onto empty metadata", func(t *testing.T) {
for _, raw := range []*string{nil, strPtr(""), strPtr("null"), strPtr("[]")} {
got, err := setMetadataString(raw, metadataSubtitle, "Editor")
if err != nil || got == nil || *got != `{"subtitle":"Editor"}` {
t.Fatalf("set on %v = %v, %v", raw, got, err)
}
cleared, err := setMetadataString(raw, metadataSubtitle, "")
if err != nil || cleared != nil {
t.Fatalf("clear on %v = %v, %v", raw, cleared, err)
}
}
})
t.Run("write keeps other keys byte-equal", func(t *testing.T) {
raw := strPtr(`{"subtitle":"a","x":{"y":[1,2]}}`)
got, err := setMetadataString(raw, metadataSubtitle, "b")
if err != nil || got == nil {
t.Fatalf("set = %v, %v", got, err)
}
var decoded map[string]json.RawMessage
if err := json.Unmarshal([]byte(*got), &decoded); err != nil {
t.Fatal(err)
}
if string(decoded["x"]) != `{"y":[1,2]}` || string(decoded["subtitle"]) != `"b"` {
t.Fatalf("metadata = %s", *got)
}
cleared, err := setMetadataString(got, metadataSubtitle, "")
if err != nil || cleared == nil || *cleared != `{"x":{"y":[1,2]}}` {
t.Fatalf("clear = %v, %v", cleared, err)
}
})
t.Run("clearing the last key is NULL", func(t *testing.T) {
got, err := setMetadataString(strPtr(`{"subtitle":"Editor"}`), metadataSubtitle, "")
if err != nil || got != nil {
t.Fatalf("clear = %v, %v", got, err)
}
})
t.Run("text that is not an object is never overwritten", func(t *testing.T) {
for _, raw := range []string{`{"subtitle":`, `"text"`, `[1]`, `5`} {
got, err := setMetadataString(strPtr(raw), metadataSubtitle, "Editor")
if !errors.Is(err, errMetadataNotObject) || got != nil {
t.Fatalf("set on %q = %v, %v", raw, got, err)
}
}
})
t.Run("values are JSON-escaped", func(t *testing.T) {
got, err := setMetadataString(nil, metadataSubtitle, `Quote " and \ back`)
if err != nil || got == nil {
t.Fatal(err)
}
if back := metadataString(got, metadataSubtitle); back != `Quote " and \ back` {
t.Fatalf("round trip = %q", back)
}
})
}
// TestBackendUserAttachContract pins the WinterCMS morph name and the avatar
// attachOne, the account helpers that need no database, and the
// unique-violation mapping.
func TestBackendUserAttachContract(t *testing.T) {
var owner attach.Owner = BackendUser{}
if owner.MorphName() != `Backend\Models\User` || BackendUserMorphType != `Backend\Models\User` {
t.Fatalf("MorphName = %q", owner.MorphName())
}
var declared attach.HasRelations = BackendUser{}
rels := declared.AttachRelations()
if len(rels) != 1 || rels[0] != (attach.Relation{Name: "avatar", Many: false, Public: true}) || BackendUserAvatarField != "avatar" {
t.Fatalf("AttachRelations = %#v", rels)
}
if AvatarMaxBytes != 2<<20 {
t.Fatalf("AvatarMaxBytes = %d", AvatarMaxBytes)
}
t.Run("avatar cap", func(t *testing.T) {
if got := (&service{}).avatarCap(); got != AvatarMaxBytes+multipartOverhead {
t.Fatalf("uncapped = %d", got)
}
if got := (&service{uploadBytes: 1 << 20}).avatarCap(); got != 1<<20 {
t.Fatalf("upload_bytes 1 MiB = %d", got)
}
if got := (&service{uploadBytes: 64 << 20}).avatarCap(); got != AvatarMaxBytes+multipartOverhead {
t.Fatalf("upload_bytes 64 MiB = %d", got)
}
})
t.Run("unique violation mapping", func(t *testing.T) {
for _, tc := range []struct {
err error
want bool
}{
{&pgconn.PgError{Code: "23505", ConstraintName: "backend_users_email_lower_unique"}, true},
{fmt.Errorf("wrapped: %w", &pgconn.PgError{Code: "23505", ConstraintName: "backend_users_email_key"}), true},
{&pgconn.PgError{Code: "23505", ConstraintName: "backend_users_login_key"}, false},
{&pgconn.PgError{Code: "23503", ConstraintName: "backend_users_email_lower_unique"}, false},
{errors.New("23505 backend_users_email_lower_unique"), false},
} {
if got := emailConflict(tc.err); got != tc.want {
t.Errorf("emailConflict(%v) = %v, want %v", tc.err, got, tc.want)
}
}
})
t.Run("messages fall back to English without a translator", func(t *testing.T) {
ctx := context.Background()
if got := backendMessage(ctx, nil, "myaccount.current_password_invalid", "fallback"); got != "fallback" {
t.Fatalf("backendMessage = %q", got)
}
for rule, want := range map[string]string{
"confirmed": "The password confirmation does not match.",
"unique": "The password has already been taken.",
"max.string": "The password may not be greater than 72 characters.",
"required": "The password field is required.",
} {
if got := accountMessage(ctx, nil, rule, "password", map[string]string{"max": "72"}); got != want {
t.Errorf("accountMessage(%s) = %q, want %q", rule, got, want)
}
}
})
t.Run("details follow the rule order", func(t *testing.T) {
details := accountDetails(map[string][]string{"password": {"b"}, "email": {"a"}})
if len(details) != 2 || details["email"].([]string)[0] != "a" || details["password"].([]string)[0] != "b" {
t.Fatalf("details = %#v", details)
}
})
t.Run("refused keys", func(t *testing.T) {
details := refusedAccountKeys(context.Background(), nil, map[string]any{"email": "x", "login": "y", "role_id": 1})
if len(details) != 2 || details["login"] == nil || details["role_id"] == nil {
t.Fatalf("details = %#v", details)
}
if refusedAccountKeys(context.Background(), nil, map[string]any{"email": "x", "subtitle": nil}) != nil {
t.Fatal("allowed keys were refused")
}
})
}
// TestAccountRoutesRefuseNonBackendPrincipals calls the three My account
// handlers without the guard: no principal, a frontend principal and a
// zero id all answer 401 before any body is read or any query runs.
func TestAccountRoutesRefuseNonBackendPrincipals(t *testing.T) {
svc := &service{}
handlers := map[string]http.HandlerFunc{
"account update": svc.accountUpdate,
"avatar upload": svc.avatarUpload,
"avatar remove": svc.avatarRemove,
}
principals := map[string]*bouncer.Principal{
"none": nil,
"frontend": {ID: 4, Backend: false},
"zero id": {ID: 0, Backend: true},
}
for name, handler := range handlers {
for kind, principal := range principals {
t.Run(name+" "+kind, func(t *testing.T) {
spy := &readSpy{r: strings.NewReader(`{"email":"x@example.test"}`)}
req := httptest.NewRequest(http.MethodPut, adminAPI("/account"), spy)
if principal != nil {
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
}
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
assertErrorCode(t, rec.Body.Bytes(), "unauthenticated")
if spy.reads != 0 {
t.Fatalf("body read %d times", spy.reads)
}
})
}
}
t.Run("backend principal without a database", func(t *testing.T) {
super := &bouncer.Principal{ID: 1, Backend: true}
for name, handler := range handlers {
req := httptest.NewRequest(http.MethodPut, adminAPI("/account"), strings.NewReader(`{"email":"admin@example.test"}`))
req = req.WithContext(bouncer.WithUser(req.Context(), super))
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("%s status=%d body=%s", name, rec.Code, rec.Body.String())
}
}
})
t.Run("avatar lookup without a database", func(t *testing.T) {
if f, err := BackendUserAvatar(context.Background(), nil, 1); f != nil || err == nil {
t.Fatalf("lookup = %v, %v", f, err)
}
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -509,3 +509,97 @@ func TestBackendAdminEmailIndexRefusesCaseDuplicates(t *testing.T) {
t.Fatal("unique lower(email) index missing")
}
}
// TestBackendAdminMetadataColumn covers 202610090001_backend_users_metadata:
// WinterCMS's nullable jsonable metadata column, added last, round-tripping a
// Winter-shaped row, and a rollback that drops only that column.
func TestBackendAdminMetadataColumn(t *testing.T) {
db, _ := dedicatedDB(t, "lagoon_admin_metadata")
gdb, err := Use(t.Context(), db)
if err != nil {
t.Fatal(err)
}
if err := Migrate(gdb, nil); err != nil {
t.Fatal(err)
}
columnType := func() (string, string, bool) {
var cols []struct {
DataType string
Nullable string
}
if err := gdb.Raw(`SELECT data_type, is_nullable AS nullable FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'backend_users' AND column_name = 'metadata'`).Scan(&cols).Error; err != nil {
t.Fatal(err)
}
if len(cols) == 0 {
return "", "", false
}
return cols[0].DataType, cols[0].Nullable, true
}
if typ, nullable, ok := columnType(); !ok || typ != "text" || nullable != "YES" {
t.Fatalf("metadata column type=%q nullable=%q present=%v", typ, nullable, ok)
}
if err := gdb.Exec(`INSERT INTO backend_users (first_name, last_name, login, email, password, permissions, metadata, is_activated, is_superuser, created_at, updated_at)
VALUES ('Ada', 'Lovelace', 'ada', 'ada@example.test', 'winter-hash', '{}', '{"subtitle":"Editor","x":1}', TRUE, FALSE, NOW(), NOW())`).Error; err != nil {
t.Fatal(err)
}
var stored *string
if err := gdb.Raw(`SELECT metadata FROM backend_users WHERE login = 'ada'`).Scan(&stored).Error; err != nil {
t.Fatal(err)
}
if stored == nil || *stored != `{"subtitle":"Editor","x":1}` {
t.Fatalf("metadata = %v", stored)
}
history, err := HistoryTableName("summercms.cabana")
if err != nil {
t.Fatal(err)
}
var ids []string
if err := gdb.Table(history).Order("id").Pluck("id", &ids).Error; err != nil {
t.Fatal(err)
}
if len(ids) == 0 || ids[len(ids)-1] != "202610090001_backend_users_metadata" {
t.Fatalf("cabana history = %v", ids)
}
if last := BackendAdminMigrations[len(BackendAdminMigrations)-1].ID; last != "202610090001_backend_users_metadata" {
t.Fatalf("last BackendAdminMigrations entry = %s", last)
}
m, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations)
if err != nil {
t.Fatal(err)
}
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}
if _, _, ok := columnType(); ok {
t.Fatal("metadata survived its rollback")
}
var rows []struct {
Login string
Email string
}
if err := gdb.Raw(`SELECT login, email FROM backend_users`).Scan(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Login != "ada" || rows[0].Email != "ada@example.test" {
t.Fatalf("rows after rollback = %+v", rows)
}
if !indexOn(indexDefs(t, gdb, "backend_users"), "lower(email)") {
t.Fatal("rolling back metadata dropped the email index")
}
if err := Migrate(gdb, nil); err != nil {
t.Fatal(err)
}
if typ, _, ok := columnType(); !ok || typ != "text" {
t.Fatalf("metadata was not added back: %q %v", typ, ok)
}
var restored *string
if err := gdb.Raw(`SELECT metadata FROM backend_users WHERE login = 'ada'`).Scan(&restored).Error; err != nil {
t.Fatal(err)
}
if restored != nil {
t.Fatalf("re-added metadata = %q, want NULL", *restored)
}
}