Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,17 @@
// Optimistic remove
reactions[messageId] = existing.filter(r => !(r.emoji === emoji && r.user_id === data.currentUserId));
try {
await fetch(`/proxy/obp/v6.0.0/chat-rooms/${data.chatRoom.chat_room_id}/messages/${messageId}/reactions`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji })
// The emoji is the last path segment on DELETE, not a body field -- the API
// serves .../reactions/{emoji}, and .../reactions is only POST and GET.
const res = await fetch(`/proxy/obp/v6.0.0/chat-rooms/${data.chatRoom.chat_room_id}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`, {
method: 'DELETE'
});
if (!res.ok) {
// Revert on failure. fetch only rejects on a network error, so without
// this check a 4xx left the reaction gone from the UI, still in the
// database, and back again on reload -- with nothing logged anywhere.
reactions[messageId] = [...(reactions[messageId] || []), myReaction];
}
} catch {
// Revert on failure
reactions[messageId] = [...(reactions[messageId] || []), myReaction];
Expand Down
14 changes: 9 additions & 5 deletions apps/portal/src/routes/user-invitation/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ export const actions = {

try {
// Option B: Create user account directly via REST API
// PUT /obp/v4.0.0/banks/{BANK_ID}/user-invitation
// POST /obp/v4.0.0/banks/{BANK_ID}/user-invitation
const requestBody: OBPUserInvitationAcceptRequestBody = {
secret_key: parseInt(secretKey),
username: username.trim(),
Expand All @@ -222,16 +222,13 @@ export const actions = {
email: email
});

const response = await obp_requests.put(
const response = await obp_requests.post(
`/obp/v4.0.0/banks/${effectiveBankId}/user-invitation`,
requestBody
);

logger.info("User account created successfully via invitation acceptance:", response);

// Redirect to login page with success message
throw redirect(303, '/login?invitation_accepted=true');

} catch (err) {
if (err instanceof OBPRequestError) {
logger.error("OBP API error during invitation acceptance:", err.message, err.code);
Expand Down Expand Up @@ -277,5 +274,12 @@ export const actions = {
success: false
};
}

// Outside the try on purpose. `redirect()` signals by throwing, so a redirect
// raised inside the block above is caught by the same catch, falls through to its
// catch-all, and turns a successful account creation into
// "Failed to create account: Unknown error". The register action already returns
// its redirect after the try for this reason.
return redirect(303, '/login?invitation_accepted=true');
}
} satisfies Actions;
105 changes: 105 additions & 0 deletions apps/portal/src/routes/user-invitation/page.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Copyright (C) 2025-2026 TESOBE GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { actions } from './+page.server.js';

// Mock the obp_requests module the action calls through. Both verbs are stubbed so the
// test can assert which one was used -- that is the whole point of the first case.
vi.mock('$lib/obp/requests', () => ({
obp_requests: {
post: vi.fn(),
put: vi.fn()
}
}));

vi.mock('$env/dynamic/public', () => ({ env: { PUBLIC_DEFAULT_BANK_ID: 'gh.29.uk' } }));
vi.mock('$env/dynamic/private', () => ({ env: {} }));

import { obp_requests } from '$lib/obp/requests';

describe('user-invitation accept action', () => {
beforeEach(() => {
vi.clearAllMocks();
});

const createMockRequest = (formData: Record<string, string>) => {
const mockFormData = new FormData();
Object.entries(formData).forEach(([key, value]) => {
mockFormData.append(key, value);
});
return { formData: () => Promise.resolve(mockFormData) };
};

const validForm = {
secret_key: '12345',
username: 'inviteduser',
password: 'password123',
confirm_password: 'password123',
first_name: 'Jane',
last_name: 'Doe',
email: 'jane@example.com',
company: 'Example Ltd',
country: 'DE',
privacy_policy: 'on',
terms_conditions: 'on',
personal_data: 'on',
bank_id: 'gh.29.uk'
};

const run = (form: Record<string, string>) =>
actions.accept({ request: createMockRequest(form) } as never);

// The regression this file exists for. OBP-API serves
// /obp/v4.0.0/banks/{BANK_ID}/user-invitation as POST only -- there is no PUT on that
// path at any version, so the previous `obp_requests.put` could only ever 404 and
// accepting an invitation could never complete. Asserting the verb, not just the URL,
// is what makes this test able to fail if it regresses.
it('accepts an invitation with POST, not PUT', async () => {
vi.mocked(obp_requests.post).mockResolvedValue({ user_id: 'user-123' });

// On success the action throws a SvelteKit redirect to the login page.
await expect(run(validForm)).rejects.toMatchObject({
status: 303,
location: '/login?invitation_accepted=true'
});

expect(obp_requests.put).not.toHaveBeenCalled();
expect(obp_requests.post).toHaveBeenCalledWith(
'/obp/v4.0.0/banks/gh.29.uk/user-invitation',
expect.objectContaining({
secret_key: 12345,
username: 'inviteduser',
email: 'jane@example.com'
})
);
});

it('rejects a password shorter than the minimum without calling the API', async () => {
const result = await run({ ...validForm, password: 'short', confirm_password: 'short' });

expect(result).toMatchObject({ success: false });
expect(obp_requests.post).not.toHaveBeenCalled();
});

it('rejects mismatched password confirmation without calling the API', async () => {
const result = await run({ ...validForm, confirm_password: 'different123' });

expect(result).toMatchObject({ success: false });
expect(obp_requests.post).not.toHaveBeenCalled();
});
});
Loading
Loading