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) + } +}