From 89611eaaf92c56104a25c0c96fd5bf60dcfa0194 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 9 Oct 2026 17:59:27 +0200 Subject: [PATCH] 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 --- admin/tests/fixtures/lang.json | 75 ++ admin/tests/shell/UserMenu.test.ts | 45 +- admin/tests/state/useAuth.test.ts | 32 +- admin/tests/views/AccountView.test.ts | 237 ++++ modules/cabana/account_internal_test.go | 246 ++++ modules/cabana/account_test.go | 1068 ++++++++++++++++- .../lagoon/backend_admin_migrations_test.go | 94 ++ 7 files changed, 1733 insertions(+), 64 deletions(-) create mode 100644 admin/tests/views/AccountView.test.ts create mode 100644 modules/cabana/account_internal_test.go diff --git a/admin/tests/fixtures/lang.json b/admin/tests/fixtures/lang.json index d5e3609..591c4fb 100644 --- a/admin/tests/fixtures/lang.json +++ b/admin/tests/fixtures/lang.json @@ -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" }, diff --git a/admin/tests/shell/UserMenu.test.ts b/admin/tests/shell/UserMenu.test.ts index 2ab6254..b7cb27c 100644 --- a/admin/tests/shell/UserMenu.test.ts +++ b/admin/tests/shell/UserMenu.test.ts @@ -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 | null) { +async function mountMenu(user: Partial | 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('[role="menuitem"]')].map((item) => item.dataset.action) + expect(items).toEqual(['account', 'logout']) + const item = content.querySelector('[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('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) diff --git a/admin/tests/state/useAuth.test.ts b/admin/tests/state/useAuth.test.ts index 182ffdf..b71bd2e 100644 --- a/admin/tests/state/useAuth.test.ts +++ b/admin/tests/state/useAuth.test.ts @@ -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') + }) +}) diff --git a/admin/tests/views/AccountView.test.ts b/admin/tests/views/AccountView.test.ts new file mode 100644 index 0000000..d07b454 --- /dev/null +++ b/admin/tests/views/AccountView.test.ts @@ -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) => ({ + status: 422, + body: { error: { code: 'validation_failed', message: 'Validation failed', details } }, +}) + +async function openAccount(user: Record = signedIn, routes: Record = {}) { + return mountApp('/account', { [`GET ${API}/auth/me`]: envelope(user), ...routes }, { attach: true }) +} + +function input(wrapper: VueWrapper, name: string) { + return wrapper.find(`#account-${name}`) +} + +async function typeInto(wrapper: VueWrapper, name: string, value: string): Promise { + await input(wrapper, name).setValue(value) +} + +async function save(wrapper: VueWrapper): Promise { + await wrapper.find('[data-action="save"]').trigger('click') + await flushPromises() +} + +async function bodyOf(request: Request): Promise> { + return (await request.clone().json()) as Record +} + +async function pickFile(wrapper: VueWrapper, file: File): Promise { + const element = wrapper.find('[data-account-avatar-input]') + Object.defineProperty(element.element, 'files', { value: [file], configurable: true }) + await element.trigger('change') + await flushPromises() +} + +const dialog = () => document.body.querySelector('[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([''], '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('[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('[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') + }) +}) diff --git a/modules/cabana/account_internal_test.go b/modules/cabana/account_internal_test.go new file mode 100644 index 0000000..e6cb146 --- /dev/null +++ b/modules/cabana/account_internal_test.go @@ -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) + } + }) +} diff --git a/modules/cabana/account_test.go b/modules/cabana/account_test.go index 7677497..11cb391 100644 --- a/modules/cabana/account_test.go +++ b/modules/cabana/account_test.go @@ -2,21 +2,31 @@ package cabana_test import ( "bytes" + "context" + "database/sql" "encoding/json" + "image" + "image/color" + "image/gif" + "io" + "log/slog" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/surf" "gocloud.dev/blob" "gocloud.dev/blob/memblob" @@ -57,16 +67,202 @@ func profileOf(t *testing.T, raw []byte) cabana.AdminProfile { return body.Data } +// validationDetails decodes the details of an error envelope. +func validationDetails(t *testing.T, raw []byte) map[string][]string { + t.Helper() + var body struct { + Error struct { + Code string `json:"code"` + Details map[string][]string `json:"details"` + } `json:"error"` + } + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("error json: %v body=%s", err, raw) + } + if body.Error.Code != "validation_failed" { + t.Fatalf("error code %q body=%s", body.Error.Code, raw) + } + return body.Error.Details +} + // signIn logs an admin in over Bearer transport and returns the token. func signIn(t *testing.T, h http.Handler, login string) string { t.Helper() - rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": login, "password": adminTestPassword}) + return signInWith(t, h, login, adminTestPassword) +} + +func signInWith(t *testing.T, h http.Handler, login, password string) string { + t.Helper() + rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": login, "password": password}) if rec.Code != http.StatusOK { t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String()) } return accessToken(t, rec.Body.Bytes()) } +// accountHandler assembles the admin router like adminHandler, with the +// phrasebook translator active, a memblob attachment bucket when withBucket +// is set and uploadBytes as http.body_limits.upload_bytes. +func accountHandler(t *testing.T, gdb *gorm.DB, withBucket bool, uploadBytes int) (http.Handler, *blob.Bucket) { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-account\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}}) + if err != nil { + t.Fatal(err) + } + // These tests sign many admins in from one address: lift the login + // throttle so it never answers 429. + for key, value := range map[string]any{"http.body_limits.default_bytes": 1 << 20, "http.body_limits.upload_bytes": uploadBytes, "admin.login.max_attempts": 1000} { + if err := cfg.Set(key, value); err != nil { + t.Fatal(err) + } + } + app := backpack.New(cfg) + if err := lagoon.Publish(app, adminSQL, gdb); err != nil { + t.Fatal(err) + } + var bucket *blob.Bucket + if withBucket { + bucket = memblob.OpenBucket(nil) + t.Cleanup(func() { _ = bucket.Close() }) + if err := attach.Publish(app, bucket); err != nil { + t.Fatal(err) + } + } + plugins := []party.Plugin{demoPlugin{fsys: demoFS()}} + if err := phrasebook.Activate(app, plugins); err != nil { + t.Fatal(err) + } + h, err := surf.Assemble(app, plugins) + if err != nil { + t.Fatal(err) + } + return h, bucket +} + +// avatarHandler is accountHandler with an 8 MiB upload cap. +func avatarHandler(t *testing.T, gdb *gorm.DB, withBucket bool) (http.Handler, *blob.Bucket) { + t.Helper() + return accountHandler(t, gdb, withBucket, 8<<20) +} + +// multipartBody builds a multipart body; each part is {field, file name, data}. +func multipartBody(t *testing.T, parts ...[3]string) ([]byte, string) { + t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + for _, p := range parts { + w, err := mw.CreateFormFile(p[0], p[1]) + if err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(w, p[2]); err != nil { + t.Fatal(err) + } + } + if err := mw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes(), mw.FormDataContentType() +} + +// sendRaw sends a raw body with a content type and a Bearer token. +func sendRaw(t *testing.T, h http.Handler, method, path, token string, body []byte, contentType string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, bytes.NewReader(body)) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +// uploadAvatar posts one multipart part (name field, file name name) to +// POST /account/avatar with a Bearer token. +func uploadAvatar(t *testing.T, h http.Handler, token, field, name string, data []byte) *httptest.ResponseRecorder { + t.Helper() + body, contentType := multipartBody(t, [3]string{field, name, string(data)}) + return sendRaw(t, h, http.MethodPost, adminAPI("/account/avatar"), token, body, contentType) +} + +// gifBytes is a small valid GIF. +func gifBytes(t *testing.T) []byte { + t.Helper() + img := image.NewPaletted(image.Rect(0, 0, 3, 3), color.Palette{color.Black, color.White}) + var buf bytes.Buffer + if err := gif.Encode(&buf, img, nil); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// avatarRows are the user's avatar rows in system_files. +func avatarRows(t *testing.T, gdb *gorm.DB, userID uint) []attach.File { + t.Helper() + var rows []attach.File + if err := gdb.Where("attachment_type = ? AND field = ? AND attachment_id = ?", cabana.BackendUserMorphType, cabana.BackendUserAvatarField, itoa(userID)).Order("id").Find(&rows).Error; err != nil { + t.Fatal(err) + } + return rows +} + +// bucketKeys lists every key under prefix. +func bucketKeys(t *testing.T, bucket *blob.Bucket, prefix string) []string { + t.Helper() + var keys []string + iter := bucket.List(&blob.ListOptions{Prefix: prefix}) + for { + obj, err := iter.Next(context.Background()) + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + keys = append(keys, obj.Key) + } + return keys +} + +// userRow is the raw backend_users columns My account must never touch +// outside its allow-list. +type userRow struct { + FirstName string + LastName string + Login string + Email string + Password string + Permissions sql.NullString + Metadata sql.NullString + IsActivated bool + IsSuperuser bool + RoleID sql.NullInt64 + UpdatedAt time.Time + TokensValid sql.NullTime +} + +func rawUser(t *testing.T, gdb *gorm.DB, id uint) userRow { + t.Helper() + var row userRow + if err := gdb.Raw(`SELECT first_name, last_name, login, email, password, permissions, metadata, is_activated, is_superuser, role_id, updated_at, tokens_valid_after AS tokens_valid +FROM backend_users WHERE id = ?`, id).Scan(&row).Error; err != nil { + t.Fatal(err) + } + return row +} + +func setMetadata(t *testing.T, gdb *gorm.DB, id uint, raw any) { + t.Helper() + if err := gdb.Exec(`UPDATE backend_users SET metadata = ? WHERE id = ?`, raw, id).Error; err != nil { + t.Fatal(err) + } +} + func TestAccountProfileSmoke(t *testing.T) { gdb := adminGorm(t) h := adminHandler(t, gdb, nil) @@ -106,67 +302,6 @@ func TestAccountProfileSmoke(t *testing.T) { } } -// avatarHandler assembles the admin router like adminHandler, with a memblob -// attachment bucket published and an 8 MiB upload cap. withBucket false -// leaves the bucket out. -func avatarHandler(t *testing.T, gdb *gorm.DB, withBucket bool) (http.Handler, *blob.Bucket) { - t.Helper() - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-account\n"), 0o644); err != nil { - t.Fatal(err) - } - cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}}) - if err != nil { - t.Fatal(err) - } - for key, value := range map[string]any{"http.body_limits.default_bytes": 1 << 20, "http.body_limits.upload_bytes": 8 << 20} { - if err := cfg.Set(key, value); err != nil { - t.Fatal(err) - } - } - app := backpack.New(cfg) - if err := lagoon.Publish(app, adminSQL, gdb); err != nil { - t.Fatal(err) - } - var bucket *blob.Bucket - if withBucket { - bucket = memblob.OpenBucket(nil) - t.Cleanup(func() { _ = bucket.Close() }) - if err := attach.Publish(app, bucket); err != nil { - t.Fatal(err) - } - } - h, err := surf.Assemble(app, []party.Plugin{demoPlugin{fsys: demoFS()}}) - if err != nil { - t.Fatal(err) - } - return h, bucket -} - -// uploadAvatar posts one multipart part (name field, file name name) to -// POST /account/avatar with a Bearer token. -func uploadAvatar(t *testing.T, h http.Handler, token, field, name string, data []byte) *httptest.ResponseRecorder { - t.Helper() - var buf bytes.Buffer - mw := multipart.NewWriter(&buf) - part, err := mw.CreateFormFile(field, name) - if err != nil { - t.Fatal(err) - } - if _, err := part.Write(data); err != nil { - t.Fatal(err) - } - if err := mw.Close(); err != nil { - t.Fatal(err) - } - req := httptest.NewRequest(http.MethodPost, adminAPI("/account/avatar"), &buf) - req.Header.Set("Content-Type", mw.FormDataContentType()) - req.Header.Set("Authorization", "Bearer "+token) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - return rec -} - func TestAccountAvatarSmoke(t *testing.T) { gdb := adminGorm(t) h, _ := avatarHandler(t, gdb, true) @@ -192,3 +327,812 @@ func TestAccountAvatarSmoke(t *testing.T) { t.Fatalf("avatar after delete = %+v", got.Avatar) } } + +// TestAuthMeProfilePayload covers the subtitle and avatar keys of GET +// /auth/me, and admins whose copied metadata is not a JSON object. +func TestAuthMeProfilePayload(t *testing.T) { + gdb := adminGorm(t) + h, bucket := avatarHandler(t, gdb, true) + stamp := accountStamp() + + rawData := func(t *testing.T, token string) map[string]json.RawMessage { + t.Helper() + rec := getAuth(t, h, adminAPI("/auth/me"), token) + if rec.Code != http.StatusOK { + t.Fatalf("me status=%d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Data map[string]json.RawMessage `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + return body.Data + } + + t.Run("fresh admin has an empty subtitle and no avatar", func(t *testing.T) { + login := "me-fresh-" + stamp + insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false) + data := rawData(t, signIn(t, h, login)) + if string(data["subtitle"]) != `""` { + t.Fatalf("subtitle = %s", data["subtitle"]) + } + if _, ok := data["avatar"]; ok { + t.Fatalf("fresh admin has an avatar key: %s", data["avatar"]) + } + }) + + t.Run("winter metadata", func(t *testing.T) { + login := "me-winter-" + stamp + user := insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false) + setMetadata(t, gdb, user.ID, `{"subtitle":"Editor","x":1}`) + data := rawData(t, signIn(t, h, login)) + if string(data["subtitle"]) != `"Editor"` { + t.Fatalf("subtitle = %s", data["subtitle"]) + } + }) + + for name, raw := range map[string]any{"empty array": "[]", "invalid json": "{broken", "blank": "", "null": nil, "number value": `{"subtitle":5}`} { + t.Run("metadata "+name+" still signs in", func(t *testing.T) { + login := "me-odd-" + strings.ReplaceAll(name, " ", "-") + "-" + stamp + user := insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false) + setMetadata(t, gdb, user.ID, raw) + data := rawData(t, signIn(t, h, login)) + if string(data["subtitle"]) != `""` { + t.Fatalf("subtitle = %s", data["subtitle"]) + } + }) + } + + t.Run("an avatar row copied from WinterCMS is picked up", func(t *testing.T) { + login := "me-copied-" + stamp + user := insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false) + disk := "c0p1ed" + strings.ReplaceAll(stamp, ".", "") + ".png" + if err := bucket.WriteAll(context.Background(), attach.BlobKey(disk), conformPNG(t), nil); err != nil { + t.Fatal(err) + } + public := true + row := attach.File{DiskName: disk, FileName: "portrait.png", FileSize: int64(len(conformPNG(t))), ContentType: "image/png", Field: "avatar", AttachmentID: itoa(user.ID), AttachmentType: `Backend\Models\User`, IsPublic: &public} + if err := gdb.Create(&row).Error; err != nil { + t.Fatal(err) + } + token := signIn(t, h, login) + got := profileOf(t, getAuth(t, h, adminAPI("/auth/me"), token).Body.Bytes()) + if got.Avatar == nil || !strings.Contains(got.Avatar.URL, disk) || !strings.Contains(got.Avatar.ThumbURL, "thumb_") { + t.Fatalf("copied avatar = %+v", got.Avatar) + } + found, err := cabana.BackendUserAvatar(context.Background(), gdb, user.ID) + if err != nil || found == nil || found.ID != row.ID { + t.Fatalf("BackendUserAvatar = %+v, %v", found, err) + } + }) + + t.Run("a protected avatar row has no public URL", func(t *testing.T) { + login := "me-protected-" + stamp + user := insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false) + private := false + row := attach.File{DiskName: "pr0tect" + strings.ReplaceAll(stamp, ".", "") + ".png", FileName: "secret.png", ContentType: "image/png", Field: "avatar", AttachmentID: itoa(user.ID), AttachmentType: cabana.BackendUserMorphType, IsPublic: &private} + if err := gdb.Create(&row).Error; err != nil { + t.Fatal(err) + } + data := rawData(t, signIn(t, h, login)) + if _, ok := data["avatar"]; ok { + t.Fatalf("protected avatar was exposed: %s", data["avatar"]) + } + }) +} + +// TestAccountProfileUpdate covers the saved and trimmed profile, the +// metadata key handling and updated_at. +func TestAccountProfileUpdate(t *testing.T) { + gdb := adminGorm(t) + h, _ := avatarHandler(t, gdb, false) + stamp := accountStamp() + login := "acct-update-" + stamp + email := login + "@example.test" + user := insertAdmin(t, gdb, login, email, adminTestPassword, true, false) + setMetadata(t, gdb, user.ID, `{"subtitle":"Old","x":1}`) + token := signIn(t, h, login) + before := rawUser(t, gdb, user.ID) + time.Sleep(20 * time.Millisecond) + + t.Run("names, email and subtitle are trimmed and saved", func(t *testing.T) { + res := putAccount(t, h, token, map[string]any{ + "first_name": " Grace ", + "last_name": " Hopper", + "email": " " + email + " ", + "subtitle": " Rear admiral ", + }) + if res.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", res.Code, res.Body) + } + got := profileOf(t, res.Body) + if got.FirstName != "Grace" || got.LastName != "Hopper" || got.Email != email || got.Subtitle != "Rear admiral" || got.ID != user.ID || got.Login != login { + t.Fatalf("profile = %+v", got) + } + row := rawUser(t, gdb, user.ID) + var meta map[string]any + if err := json.Unmarshal([]byte(row.Metadata.String), &meta); err != nil { + t.Fatalf("metadata %q: %v", row.Metadata.String, err) + } + if meta["subtitle"] != "Rear admiral" || meta["x"] != float64(1) || len(meta) != 2 { + t.Fatalf("metadata = %v", meta) + } + if !row.UpdatedAt.After(before.UpdatedAt) { + t.Fatalf("updated_at %v did not advance from %v", row.UpdatedAt, before.UpdatedAt) + } + if row.Password != before.Password || row.TokensValid.Valid { + t.Fatal("a profile save without a password changed the password or the token cutoff") + } + }) + + t.Run("an empty subtitle removes the key and keeps the others", func(t *testing.T) { + res := putAccount(t, h, token, map[string]any{"first_name": "Grace", "last_name": "Hopper", "email": email, "subtitle": ""}) + if res.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", res.Code, res.Body) + } + if row := rawUser(t, gdb, user.ID); row.Metadata.String != `{"x":1}` { + t.Fatalf("metadata = %q", row.Metadata.String) + } + if got := profileOf(t, res.Body); got.Subtitle != "" { + t.Fatalf("subtitle = %q", got.Subtitle) + } + }) + + t.Run("absent and null keys save empty values", func(t *testing.T) { + if res := putAccount(t, h, token, map[string]any{"subtitle": "Back", "email": email}); res.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", res.Code, res.Body) + } + res := putAccount(t, h, token, map[string]any{"email": email, "first_name": nil}) + if res.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", res.Code, res.Body) + } + got := profileOf(t, res.Body) + if got.FirstName != "" || got.LastName != "" || got.Subtitle != "" { + t.Fatalf("profile = %+v", got) + } + }) + + t.Run("the last key cleared stores NULL", func(t *testing.T) { + setMetadata(t, gdb, user.ID, `{"subtitle":"Only"}`) + if res := putAccount(t, h, token, map[string]any{"email": email}); res.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", res.Code, res.Body) + } + if row := rawUser(t, gdb, user.ID); row.Metadata.Valid { + t.Fatalf("metadata = %q, want NULL", row.Metadata.String) + } + }) + + t.Run("metadata that is not an object is never overwritten", func(t *testing.T) { + setMetadata(t, gdb, user.ID, `"legacy text"`) + ok := putAccount(t, h, token, map[string]any{"email": email, "first_name": "Kept"}) + if ok.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", ok.Code, ok.Body) + } + refused := putAccount(t, h, token, map[string]any{"email": email, "subtitle": "New"}) + if refused.Code != http.StatusUnprocessableEntity { + t.Fatalf("status=%d body=%s", refused.Code, refused.Body) + } + if details := validationDetails(t, refused.Body); len(details["subtitle"]) != 1 { + t.Fatalf("details = %v", details) + } + if row := rawUser(t, gdb, user.ID); row.Metadata.String != `"legacy text"` || row.FirstName != "Kept" { + t.Fatalf("row = %+v", row) + } + }) + + t.Run("field rules", func(t *testing.T) { + long := strings.Repeat("a", 256) + for name, body := range map[string]map[string]any{ + "first_name": {"email": email, "first_name": long}, + "last_name": {"email": email, "last_name": 5}, + "subtitle": {"email": email, "subtitle": long}, + } { + res := putAccount(t, h, token, body) + if res.Code != http.StatusUnprocessableEntity { + t.Fatalf("%s status=%d body=%s", name, res.Code, res.Body) + } + if details := validationDetails(t, res.Body); len(details[name]) == 0 { + t.Fatalf("%s details = %v", name, details) + } + } + }) + + t.Run("malformed and oversized bodies", func(t *testing.T) { + for _, raw := range []string{`{`, `[]`, `{"email":"x"} {}`, `"text"`} { + rec := postAuth(t, h, http.MethodPut, adminAPI("/account"), token, []byte(raw)) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("%s status=%d body=%s", raw, rec.Code, rec.Body.String()) + } + } + huge, _ := json.Marshal(map[string]string{"email": email, "subtitle": strings.Repeat("x", 2<<20)}) + rec := postAuth(t, h, http.MethodPut, adminAPI("/account"), token, huge) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("huge status=%d", rec.Code) + } + }) +} + +// TestAccountForbiddenKeys covers the server-side allow-list and the +// guarantee that only the token subject's row is written. +func TestAccountForbiddenKeys(t *testing.T) { + gdb := adminGorm(t) + h, _ := avatarHandler(t, gdb, false) + stamp := accountStamp() + login := "acct-keys-" + stamp + email := login + "@example.test" + user := insertAdmin(t, gdb, login, email, adminTestPassword, true, false) + other := insertAdmin(t, gdb, "acct-keys-other-"+stamp, "acct-keys-other-"+stamp+"@example.test", adminTestPassword, true, false) + token := signIn(t, h, login) + var publisherID uint + if err := gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'publisher'`).Scan(&publisherID).Error; err != nil || publisherID == 0 { + t.Fatalf("publisher role: %d %v", publisherID, err) + } + before := rawUser(t, gdb, user.ID) + + for key, value := range map[string]any{ + "login": "hijack-" + stamp, + "role_id": publisherID, + "is_superuser": true, + "permissions": map[string]any{"acme.demo.access": 1}, + "is_activated": false, + "id": other.ID, + "password_hash": "x", + "metadata": map[string]any{"subtitle": "x"}, + } { + t.Run(key, func(t *testing.T) { + res := putAccount(t, h, token, map[string]any{"email": email, "first_name": "Changed", key: value}) + if res.Code != http.StatusUnprocessableEntity { + t.Fatalf("status=%d body=%s", res.Code, res.Body) + } + details := validationDetails(t, res.Body) + if len(details) != 1 || len(details[key]) != 1 || details[key][0] != "This field cannot be changed on My account." { + t.Fatalf("details = %v", details) + } + if after := rawUser(t, gdb, user.ID); after != before { + t.Fatalf("row changed:\nbefore %+v\nafter %+v", before, after) + } + }) + } + + t.Run("refusals are translated", func(t *testing.T) { + raw, _ := json.Marshal(map[string]any{"email": email, "login": "x"}) + req := httptest.NewRequest(http.MethodPut, adminAPI("/account"), bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept-Language", "pl") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if details := validationDetails(t, rec.Body.Bytes()); len(details["login"]) != 1 || details["login"][0] != "Tego pola nie można zmienić w ustawieniach konta." { + t.Fatalf("details = %v", details) + } + }) + + t.Run("only the token subject is written", func(t *testing.T) { + otherBefore := rawUser(t, gdb, other.ID) + res := putAccount(t, h, token, map[string]any{"email": email, "first_name": "Mine", "subtitle": "Me"}) + if res.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", res.Code, res.Body) + } + if got := profileOf(t, res.Body); got.ID != user.ID || got.FirstName != "Mine" { + t.Fatalf("profile = %+v", got) + } + if after := rawUser(t, gdb, other.ID); after != otherBefore { + t.Fatalf("other admin changed:\nbefore %+v\nafter %+v", otherBefore, after) + } + if row := rawUser(t, gdb, user.ID); row.Login != login || row.IsSuperuser || row.RoleID != before.RoleID || row.Permissions != before.Permissions || !row.IsActivated { + t.Fatalf("protected columns changed: %+v", row) + } + }) +} + +// TestAccountEmailRules covers the email rules of PUT /account. +func TestAccountEmailRules(t *testing.T) { + gdb := adminGorm(t) + h, _ := avatarHandler(t, gdb, false) + stamp := accountStamp() + login := "acct-email-" + stamp + email := login + "@example.test" + insertAdmin(t, gdb, login, email, adminTestPassword, true, false) + other := insertAdmin(t, gdb, "acct-email-other-"+stamp, "Other-"+stamp+"@Example.Test", adminTestPassword, true, false) + loginLike := insertAdmin(t, gdb, "login-"+stamp+"@example.test", "login-owner-"+stamp+"@example.test", adminTestPassword, true, false) + deleted := insertAdmin(t, gdb, "acct-email-gone-"+stamp, "gone-"+stamp+"@example.test", adminTestPassword, true, true) + token := signIn(t, h, login) + + for name, value := range map[string]any{ + "missing": nil, + "blank": " ", + "invalid": "not-an-email", + "short": "a@b.c", + "too long": strings.Repeat("a", 250) + "@example.test", + "another admin": strings.ToUpper(other.Email), + "another admin login": strings.ToUpper(loginLike.Login), + "a deleted admin": deleted.Email, + } { + t.Run(name, func(t *testing.T) { + body := map[string]any{"first_name": "X"} + if value != nil { + body["email"] = value + } + res := putAccount(t, h, token, body) + if res.Code != http.StatusUnprocessableEntity { + t.Fatalf("status=%d body=%s", res.Code, res.Body) + } + if details := validationDetails(t, res.Body); len(details["email"]) == 0 { + t.Fatalf("details = %v", details) + } + var stored string + if err := gdb.Raw(`SELECT email FROM backend_users WHERE login = ?`, login).Scan(&stored).Error; err != nil || stored != email { + t.Fatalf("email = %q %v", stored, err) + } + }) + } + + t.Run("taken message", func(t *testing.T) { + res := putAccount(t, h, token, map[string]any{"email": other.Email}) + if details := validationDetails(t, res.Body); details["email"][0] != "The email has already been taken." { + t.Fatalf("details = %v", details) + } + }) + + t.Run("own email in another case is accepted", func(t *testing.T) { + res := putAccount(t, h, token, map[string]any{"email": strings.ToUpper(email)}) + if res.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", res.Code, res.Body) + } + if got := profileOf(t, res.Body); got.Email != strings.ToUpper(email) { + t.Fatalf("email = %q", got.Email) + } + }) +} + +// cookieLogin signs in over cookie transport and returns the session cookie. +func cookieLogin(t *testing.T, h http.Handler, login string) *http.Cookie { + t.Helper() + raw, _ := json.Marshal(map[string]string{"login": login, "password": adminTestPassword}) + req := httptest.NewRequest(http.MethodPost, adminAPI("/auth/login"), bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if c := sessionCookie(rec); c != nil && rec.Code == http.StatusOK { + return c + } + t.Fatalf("cookie login status=%d body=%s", rec.Code, rec.Body.String()) + return nil +} + +func sessionCookie(rec *httptest.ResponseRecorder) *http.Cookie { + for _, c := range rec.Result().Cookies() { + if c.Name == cabana.AdminCookieName && c.Value != "" && c.MaxAge > 0 { + return c + } + } + return nil +} + +func cookieRequest(t *testing.T, h http.Handler, method, path string, cookie *http.Cookie, body any, ajax bool) *httptest.ResponseRecorder { + t.Helper() + var payload []byte + if body != nil { + payload, _ = json.Marshal(body) + } + req := httptest.NewRequest(method, path, bytes.NewReader(payload)) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if ajax { + req.Header.Set("X-Requested-With", "XMLHttpRequest") + } + req.AddCookie(&http.Cookie{Name: cookie.Name, Value: cookie.Value}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +// nextSecond sleeps into the next wall-clock second, so a token minted +// before the call has an earlier iat than anything after it. +func nextSecond() { + now := time.Now() + time.Sleep(now.Truncate(time.Second).Add(time.Second).Sub(now) + 5*time.Millisecond) +} + +// TestAccountPasswordChange covers the password rules, the failed-attempt +// log and the session effects of a password change. +func TestAccountPasswordChange(t *testing.T) { + gdb := adminGorm(t) + h, _ := avatarHandler(t, gdb, false) + stamp := accountStamp() + const newPassword = "brand-new-secret" + + newAdmin := func(t *testing.T, suffix string) (cabana.BackendUser, string) { + t.Helper() + login := "acct-pw-" + suffix + "-" + stamp + return insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false), login + "@example.test" + } + + refusals := map[string]struct { + body map[string]any + field string + }{ + "missing current password": {map[string]any{"password": newPassword, "password_confirmation": newPassword}, "current_password"}, + "mismatched confirmation": {map[string]any{"current_password": adminTestPassword, "password": newPassword, "password_confirmation": "other"}, "password"}, + "too short": {map[string]any{"current_password": adminTestPassword, "password": "abc", "password_confirmation": "abc"}, "password"}, + "over 72 bytes": {map[string]any{"current_password": adminTestPassword, "password": strings.Repeat("ż", 40), "password_confirmation": strings.Repeat("ż", 40)}, "password"}, + "current password not a string": {map[string]any{"current_password": 5, "password": newPassword, "password_confirmation": newPassword}, "current_password"}, + } + for name, tc := range refusals { + t.Run(name, func(t *testing.T) { + user, email := newAdmin(t, strings.ReplaceAll(name, " ", "-")) + token := signIn(t, h, user.Login) + before := rawUser(t, gdb, user.ID) + body := map[string]any{"email": email} + for k, v := range tc.body { + body[k] = v + } + res := putAccount(t, h, token, body) + if res.Code != http.StatusUnprocessableEntity { + t.Fatalf("status=%d body=%s", res.Code, res.Body) + } + if details := validationDetails(t, res.Body); len(details[tc.field]) == 0 { + t.Fatalf("details = %v", details) + } + if after := rawUser(t, gdb, user.ID); after.Password != before.Password || after.TokensValid.Valid { + t.Fatal("a refused change wrote the password or the cutoff") + } + }) + } + + t.Run("wrong current password is logged", func(t *testing.T) { + user, email := newAdmin(t, "wrong") + token := signIn(t, h, user.Login) + before := rawUser(t, gdb, user.ID) + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil))) + res := putAccount(t, h, token, map[string]any{"email": email, "current_password": "not-the-password", "password": newPassword, "password_confirmation": newPassword}) + slog.SetDefault(prev) + if res.Code != http.StatusUnprocessableEntity { + t.Fatalf("status=%d body=%s", res.Code, res.Body) + } + if details := validationDetails(t, res.Body); len(details["current_password"]) != 1 || details["current_password"][0] != "The current password is incorrect." { + t.Fatalf("details = %v", details) + } + assertLog(t, &buf, "failed", user.ID, "not-the-password", token) + if strings.Contains(buf.String(), newPassword) { + t.Fatalf("log contains the new password: %s", buf.String()) + } + if after := rawUser(t, gdb, user.ID); after.Password != before.Password { + t.Fatal("the hash changed after a wrong current password") + } + }) + + t.Run("success over Bearer ends every other session", func(t *testing.T) { + user, email := newAdmin(t, "bearer") + earlier := signIn(t, h, user.Login) + nextSecond() + current := signIn(t, h, user.Login) + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil))) + res := putAccount(t, h, current, map[string]any{"email": email, "current_password": adminTestPassword, "password": newPassword, "password_confirmation": newPassword}) + slog.SetDefault(prev) + if res.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", res.Code, res.Body) + } + if c := sessionCookieOf(res.Header); c != nil { + t.Fatal("a Bearer request received a session cookie") + } + assertLog(t, &buf, "password_changed", user.ID, newPassword, current) + row := rawUser(t, gdb, user.ID) + if !bouncer.CheckPassword(row.Password, newPassword) || bouncer.CheckPassword(row.Password, adminTestPassword) { + t.Fatal("the stored hash does not match the new password") + } + if !row.TokensValid.Valid || row.TokensValid.Time.Nanosecond() != 0 || time.Since(row.TokensValid.Time) > time.Minute { + t.Fatalf("tokens_valid_after = %+v", row.TokensValid) + } + for name, token := range map[string]string{"earlier session": earlier, "changing session": current} { + if rec := getAuth(t, h, adminAPI("/auth/me"), token); rec.Code != http.StatusUnauthorized { + t.Fatalf("%s status=%d", name, rec.Code) + } + } + if rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": user.Login, "password": adminTestPassword}); rec.Code != http.StatusUnauthorized { + t.Fatalf("old password login status=%d", rec.Code) + } + fresh := signInWith(t, h, user.Login, newPassword) + if rec := getAuth(t, h, adminAPI("/auth/me"), fresh); rec.Code != http.StatusOK { + t.Fatalf("fresh session status=%d", rec.Code) + } + }) + + t.Run("success over cookie keeps the browser signed in", func(t *testing.T) { + user, email := newAdmin(t, "cookie") + old := cookieLogin(t, h, user.Login) + rec := cookieRequest(t, h, http.MethodPut, adminAPI("/account"), old, map[string]any{"email": email, "current_password": adminTestPassword, "password": newPassword, "password_confirmation": newPassword}, true) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + next := sessionCookie(rec) + if next == nil || next.Value == old.Value || !next.HttpOnly || next.SameSite != http.SameSiteStrictMode { + t.Fatalf("fresh cookie = %+v", next) + } + if strings.Contains(rec.Body.String(), next.Value) { + t.Fatal("the cookie token leaked into the body") + } + if me := cookieRequest(t, h, http.MethodGet, adminAPI("/auth/me"), next, nil, false); me.Code != http.StatusOK { + t.Fatalf("fresh cookie /auth/me status=%d", me.Code) + } + if me := cookieRequest(t, h, http.MethodGet, adminAPI("/auth/me"), old, nil, false); me.Code != http.StatusUnauthorized { + t.Fatalf("old cookie /auth/me status=%d", me.Code) + } + }) + + t.Run("cookie request without the CSRF header is refused", func(t *testing.T) { + user, email := newAdmin(t, "csrf") + cookie := cookieLogin(t, h, user.Login) + before := rawUser(t, gdb, user.ID) + rec := cookieRequest(t, h, http.MethodPut, adminAPI("/account"), cookie, map[string]any{"email": email, "first_name": "Forged"}, false) + if rec.Code != http.StatusForbidden { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if after := rawUser(t, gdb, user.ID); after != before { + t.Fatal("a forged request wrote the row") + } + }) +} + +func sessionCookieOf(header http.Header) *http.Cookie { + rec := httptest.NewRecorder() + for _, v := range header.Values("Set-Cookie") { + rec.Header().Add("Set-Cookie", v) + } + return sessionCookie(rec) +} + +// TestAccountAvatarUpload covers the stored row, the generated storage name +// and the URLs on the profile. +func TestAccountAvatarUpload(t *testing.T) { + gdb := adminGorm(t) + h, bucket := avatarHandler(t, gdb, true) + login := "acct-av-up-" + accountStamp() + user := insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false) + token := signIn(t, h, login) + + up := uploadAvatar(t, h, token, "file_data", "../../evil.png", conformPNG(t)) + if up.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", up.Code, up.Body.String()) + } + rows := avatarRows(t, gdb, user.ID) + if len(rows) != 1 { + t.Fatalf("avatar rows = %d", len(rows)) + } + f := rows[0] + if f.AttachmentType != `Backend\Models\User` || f.Field != "avatar" || f.AttachmentID != itoa(user.ID) || !f.Public() || f.ContentType != "image/png" { + t.Fatalf("row = %+v", f) + } + if f.FileName != "evil.png" || strings.Contains(f.DiskName, "/") || strings.Contains(f.DiskName, "evil") || strings.Contains(f.DiskName, "..") { + t.Fatalf("names: file_name=%q disk_name=%q", f.FileName, f.DiskName) + } + key := attach.BlobKey(f.DiskName) + if !strings.HasPrefix(key, attach.PartitionDirectory(f.DiskName)) || strings.Contains(key, "..") { + t.Fatalf("blob key %q", key) + } + if ok, err := bucket.Exists(context.Background(), key); err != nil || !ok { + t.Fatalf("original blob exists=%v err=%v", ok, err) + } + uploaded := profileOf(t, up.Body.Bytes()) + me := profileOf(t, getAuth(t, h, adminAPI("/auth/me"), token).Body.Bytes()) + for name, p := range map[string]cabana.AdminProfile{"upload": uploaded, "me": me} { + if p.Avatar == nil || p.Avatar.URL != f.URL() || p.Avatar.ThumbURL == "" || !strings.Contains(p.Avatar.ThumbURL, "thumb_") || !strings.Contains(p.Avatar.ThumbURL, "250_250") { + t.Fatalf("%s avatar = %+v (url %s)", name, p.Avatar, f.URL()) + } + } + found, err := cabana.BackendUserAvatar(context.Background(), gdb, user.ID) + if err != nil || found == nil || found.ID != f.ID { + t.Fatalf("BackendUserAvatar = %+v, %v", found, err) + } +} + +// TestAccountAvatarRefusals covers what the upload refuses; no refusal +// leaves a row or a blob behind. +func TestAccountAvatarRefusals(t *testing.T) { + gdb := adminGorm(t) + h, bucket := avatarHandler(t, gdb, true) + stamp := accountStamp() + login := "acct-av-ref-" + stamp + user := insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false) + token := signIn(t, h, login) + png := string(conformPNG(t)) + oversized := png + strings.Repeat("\x00", int(cabana.AvatarMaxBytes)) + countFiles := func() int64 { + var n int64 + if err := gdb.Model(&attach.File{}).Count(&n).Error; err != nil { + t.Fatal(err) + } + return n + } + + for _, tc := range []struct { + name string + parts [][3]string + status int + detail string + }{ + {"svg", [][3]string{{"file_data", "logo.svg", ``}}, http.StatusUnprocessableEntity, "avatar"}, + {"html named png", [][3]string{{"file_data", "fake.png", ``}}, http.StatusUnprocessableEntity, "avatar"}, + {"text file", [][3]string{{"file_data", "notes.txt", "hello"}}, http.StatusUnprocessableEntity, "avatar"}, + {"over AvatarMaxBytes", [][3]string{{"file_data", "big.png", oversized}}, http.StatusUnprocessableEntity, "avatar"}, + {"wrong part name", [][3]string{{"file", "me.png", png}}, http.StatusUnprocessableEntity, "body"}, + {"no file name", [][3]string{{"file_data", " ", png}}, http.StatusUnprocessableEntity, "body"}, + {"no part", nil, http.StatusUnprocessableEntity, "body"}, + {"two parts", [][3]string{{"file_data", "a.png", png}, {"file_data", "b.png", png}}, http.StatusUnprocessableEntity, "body"}, + } { + t.Run(tc.name, func(t *testing.T) { + before := countFiles() + body, contentType := multipartBody(t, tc.parts...) + rec := sendRaw(t, h, http.MethodPost, adminAPI("/account/avatar"), token, body, contentType) + if rec.Code != tc.status { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if details := validationDetails(t, rec.Body.Bytes()); len(details[tc.detail]) == 0 { + t.Fatalf("details = %v", details) + } + if after := countFiles(); after != before { + t.Fatalf("system_files rows %d -> %d", before, after) + } + if keys := bucketKeys(t, bucket, ""); len(keys) != 0 { + t.Fatalf("blobs left behind: %v", keys) + } + }) + } + + t.Run("refusal messages", func(t *testing.T) { + svg, ct := multipartBody(t, [3]string{"file_data", "logo.svg", ""}) + rec := sendRaw(t, h, http.MethodPost, adminAPI("/account/avatar"), token, svg, ct) + if got := validationDetails(t, rec.Body.Bytes())["avatar"][0]; got != "The avatar must be a file of type: jpg, jpeg, png, gif, webp." { + t.Fatalf("svg message = %q", got) + } + big, ct := multipartBody(t, [3]string{"file_data", "big.png", oversized}) + rec = sendRaw(t, h, http.MethodPost, adminAPI("/account/avatar"), token, big, ct) + if got := validationDetails(t, rec.Body.Bytes())["avatar"][0]; got != "The avatar may not be greater than 2048 kilobytes." { + t.Fatalf("size message = %q", got) + } + html, ct := multipartBody(t, [3]string{"file_data", "fake.png", ""}) + rec = sendRaw(t, h, http.MethodPost, adminAPI("/account/avatar"), token, html, ct) + if got := validationDetails(t, rec.Body.Bytes())["avatar"][0]; got != "The avatar must be an image." { + t.Fatalf("image message = %q", got) + } + }) + + t.Run("malformed bodies", func(t *testing.T) { + for name, rec := range map[string]*httptest.ResponseRecorder{ + "json": sendRaw(t, h, http.MethodPost, adminAPI("/account/avatar"), token, []byte(`{}`), "application/json"), + "bad boundary": sendRaw(t, h, http.MethodPost, adminAPI("/account/avatar"), token, []byte("--other\r\ngarbage"), "multipart/form-data; boundary=expected"), + } { + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("%s status=%d body=%s", name, rec.Code, rec.Body.String()) + } + } + }) + + t.Run("a body over upload_bytes is 413", func(t *testing.T) { + small, smallBucket := accountHandler(t, gdb, true, 1<<20) + before := countFiles() + body, contentType := multipartBody(t, [3]string{"file_data", "big.png", png + strings.Repeat("\x00", 3<<19)}) + rec := sendRaw(t, small, http.MethodPost, adminAPI("/account/avatar"), token, body, contentType) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if countFiles() != before || len(bucketKeys(t, smallBucket, "")) != 0 { + t.Fatal("an oversized body left a row or a blob") + } + }) + + t.Run("no bucket is a 500", func(t *testing.T) { + bare, _ := avatarHandler(t, gdb, false) + body, contentType := multipartBody(t, [3]string{"file_data", "me.png", png}) + rec := sendRaw(t, bare, http.MethodPost, adminAPI("/account/avatar"), token, body, contentType) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + }) + + if rows := avatarRows(t, gdb, user.ID); len(rows) != 0 { + t.Fatalf("refusals stored %d avatar rows", len(rows)) + } +} + +// TestAccountAvatarReplaceAndRemove covers replace semantics, removal, the +// idempotent DELETE and another admin's untouched avatar. +func TestAccountAvatarReplaceAndRemove(t *testing.T) { + gdb := adminGorm(t) + h, bucket := avatarHandler(t, gdb, true) + stamp := accountStamp() + login := "acct-av-rep-" + stamp + user := insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false) + otherLogin := "acct-av-other-" + stamp + other := insertAdmin(t, gdb, otherLogin, otherLogin+"@example.test", adminTestPassword, true, false) + token := signIn(t, h, login) + ctx := context.Background() + + if rec := uploadAvatar(t, h, signIn(t, h, otherLogin), "file_data", "other.png", conformPNG(t)); rec.Code != http.StatusOK { + t.Fatalf("other upload status=%d", rec.Code) + } + otherRows := avatarRows(t, gdb, other.ID) + + if rec := uploadAvatar(t, h, token, "file_data", "first.png", conformPNG(t)); rec.Code != http.StatusOK { + t.Fatalf("first upload status=%d body=%s", rec.Code, rec.Body.String()) + } + first := avatarRows(t, gdb, user.ID) + if len(first) != 1 { + t.Fatalf("rows after first upload = %d", len(first)) + } + firstThumbs := attach.PartitionDirectory(first[0].DiskName) + "thumb_" + itoa(first[0].ID) + "_" + if keys := bucketKeys(t, bucket, firstThumbs); len(keys) == 0 { + t.Fatal("the first upload's profile made no thumbnail") + } + + second := uploadAvatar(t, h, token, "file_data", "second.gif", gifBytes(t)) + if second.Code != http.StatusOK { + t.Fatalf("second upload status=%d body=%s", second.Code, second.Body.String()) + } + rows := avatarRows(t, gdb, user.ID) + if len(rows) != 1 || rows[0].ID == first[0].ID || rows[0].ContentType != "image/gif" { + t.Fatalf("rows after replace = %+v", rows) + } + if ok, _ := bucket.Exists(ctx, attach.BlobKey(first[0].DiskName)); ok { + t.Fatal("the replaced original blob is still stored") + } + if keys := bucketKeys(t, bucket, firstThumbs); len(keys) != 0 { + t.Fatalf("the replaced thumbnails are still stored: %v", keys) + } + if p := profileOf(t, second.Body.Bytes()); p.Avatar == nil || p.Avatar.URL != rows[0].URL() { + t.Fatalf("replace profile avatar = %+v", p.Avatar) + } + + del := postAuth(t, h, http.MethodDelete, adminAPI("/account/avatar"), token, nil) + if del.Code != http.StatusOK { + t.Fatalf("delete status=%d body=%s", del.Code, del.Body.String()) + } + if strings.Contains(del.Body.String(), `"avatar"`) { + t.Fatalf("delete answered an avatar: %s", del.Body.String()) + } + if left := avatarRows(t, gdb, user.ID); len(left) != 0 { + t.Fatalf("rows after delete = %d", len(left)) + } + part := attach.PartitionDirectory(rows[0].DiskName) + if ok, _ := bucket.Exists(ctx, attach.BlobKey(rows[0].DiskName)); ok { + t.Fatal("the removed original blob is still stored") + } + if keys := bucketKeys(t, bucket, part+"thumb_"+itoa(rows[0].ID)+"_"); len(keys) != 0 { + t.Fatalf("the removed thumbnails are still stored: %v", keys) + } + + again := postAuth(t, h, http.MethodDelete, adminAPI("/account/avatar"), token, nil) + if again.Code != http.StatusOK { + t.Fatalf("second delete status=%d body=%s", again.Code, again.Body.String()) + } + if found, err := cabana.BackendUserAvatar(ctx, gdb, user.ID); found != nil || err != nil { + t.Fatalf("BackendUserAvatar = %+v, %v", found, err) + } + if after := avatarRows(t, gdb, other.ID); len(after) != 1 || after[0].ID != otherRows[0].ID { + t.Fatalf("another admin's avatar changed: %+v", after) + } + + t.Run("removal without a bucket keeps the blobs", func(t *testing.T) { + if rec := uploadAvatar(t, h, token, "file_data", "third.png", conformPNG(t)); rec.Code != http.StatusOK { + t.Fatalf("upload status=%d", rec.Code) + } + third := avatarRows(t, gdb, user.ID) + bare, _ := avatarHandler(t, gdb, false) + if rec := postAuth(t, bare, http.MethodDelete, adminAPI("/account/avatar"), token, nil); rec.Code != http.StatusOK { + t.Fatalf("delete status=%d body=%s", rec.Code, rec.Body.String()) + } + if left := avatarRows(t, gdb, user.ID); len(left) != 0 { + t.Fatalf("rows after bucketless delete = %d", len(left)) + } + if ok, _ := bucket.Exists(ctx, attach.BlobKey(third[0].DiskName)); !ok { + t.Fatal("a delete without a bucket cannot have removed the blob") + } + }) +} diff --git a/modules/lagoon/backend_admin_migrations_test.go b/modules/lagoon/backend_admin_migrations_test.go index 7f3df44..c7c2010 100644 --- a/modules/lagoon/backend_admin_migrations_test.go +++ b/modules/lagoon/backend_admin_migrations_test.go @@ -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) + } +}