From 31070bdbf67cc840650ad02f083a66d0921cdb0e Mon Sep 17 00:00:00 2001 From: Vitaly Date: Thu, 23 Jul 2026 19:45:58 +0200 Subject: [PATCH] Fix reported gameplay and account issues --- AGENTS.md | 2 + .../assets/js/__tests__/GameRecovery.test.ts | 30 ++ .../assets/js/__tests__/UserSettings.test.tsx | 140 +++++++++- .../assets/js/widgets/middlewares/Invite.ts | 10 +- .../assets/js/widgets/middlewares/Lobby.ts | 26 +- .../widgets/pages/game/ActionsAfterGame.tsx | 5 + .../js/widgets/pages/lobby/LobbyWidget.tsx | 3 +- .../js/widgets/pages/lobby/TaskChoice.tsx | 28 +- .../pages/schedule/TournamentSchedule.tsx | 9 +- .../widgets/pages/settings/UserSettings.tsx | 159 ++++++++++- .../pages/settings/UserSettingsForm.tsx | 164 ++++++++--- .../pages/tournament/PlayersRankingPanel.tsx | 2 +- apps/codebattle/lib/codebattle/application.ex | 4 + apps/codebattle/lib/codebattle/bot/server.ex | 4 +- .../lib/codebattle/forms/task_pack.ex | 48 +++- apps/codebattle/lib/codebattle/invite.ex | 19 +- .../lib/codebattle/tournament/context.ex | 7 +- apps/codebattle/lib/codebattle/user.ex | 92 ++++++- .../codebattle/lib/codebattle/user_session.ex | 257 ++++++++++++++++++ .../codebattle_web/channels/invite_channel.ex | 31 +++ .../codebattle_web/channels/user_socket.ex | 24 +- .../controllers/api/v1/session_controller.ex | 5 +- .../controllers/api/v1/settings_controller.ex | 113 ++++++++ .../controllers/api/v1/user_controller.ex | 4 +- .../controllers/auth_controller.ex | 7 +- .../controllers/dev_login_controller.ex | 5 +- .../ext_api/load_test_controller.ex | 4 +- .../controllers/game_controller.ex | 4 +- .../controllers/session_controller.ex | 6 +- .../live/admin/user_show_view.ex | 3 +- .../plugs/assign_current_user.ex | 67 +++-- .../plugs/assign_inertia_props.ex | 37 ++- apps/codebattle/lib/codebattle_web/router.ex | 3 + .../lib/codebattle_web/user_auth.ex | 63 +++++ .../views/admin/dev_solution_tester_view.ex | 1 - .../lib/codebattle_web/views/api/task_view.ex | 2 + .../codebattle_web/views/group_task_view.ex | 1 - .../lib/codebattle_web/views/raw_task_view.ex | 1 - .../priv/gettext/en/LC_MESSAGES/default.po | 99 ++++++- .../priv/gettext/ru/LC_MESSAGES/default.po | 96 ++++++- .../20260723190000_create_user_sessions.exs | 23 ++ .../codebattle/bot/playbook_player_test.exs | 2 +- .../codebattle/bot/playbook_store_test.exs | 2 +- .../test/codebattle/game/elo_test.exs | 2 + .../test/codebattle/invite_test.exs | 29 ++ .../codebattle/tournament/context_test.exs | 21 ++ .../channels/chat_channel_test.exs | 6 +- .../channels/game_channel_test.exs | 6 +- .../group_tournament_channel_test.exs | 4 +- .../channels/invite_channel_test.exs | 4 +- .../channels/lobby_channel_test.exs | 16 +- .../channels/main_channel_test.exs | 4 +- .../tournament_admin_channel_auth_test.exs | 4 +- .../tournament_admin_channel_test.exs | 2 +- .../channels/tournament_channel_auth_test.exs | 4 +- .../channels/tournament_channel_test.exs | 4 +- .../tournament_stream_channel_test.exs | 2 +- .../channels/user_socket_test.exs | 24 +- .../admin/group_task_controller_test.exs | 8 +- .../group_tournament_json_controller_test.exs | 8 +- .../controllers/admin_feedback_live_test.exs | 14 +- .../controllers/admin_user_show_live_test.exs | 6 +- .../api/v1/activity_controller_test.exs | 6 +- .../v1/event/leaderboard_controller_test.exs | 18 +- .../api/v1/feedback_controller_test.exs | 4 +- .../v1/group_tournament_controller_test.exs | 2 +- .../api/v1/settings_controller_test.exs | 171 +++++++++++- .../api/v1/stream_config_controller_test.exs | 2 +- .../api/v1/task_controller_test.exs | 10 +- .../api/v1/tournament_controller_test.exs | 36 +-- .../api/v1/user_controller_test.exs | 2 +- .../v1/user_game_report_controller_test.exs | 12 +- .../controllers/auth_bind_controller_test.exs | 12 +- .../controllers/clan_controller_test.exs | 2 +- .../controllers/dev_login_controller_test.exs | 26 ++ .../controllers/event_controller_test.exs | 2 +- .../controllers/game_controller_test.exs | 34 ++- .../group_tournament_controller_test.exs | 18 +- .../inertia_pages_controller_test.exs | 2 +- .../controllers/locale_test.exs | 8 +- .../public_event_controller_test.exs | 12 +- .../controllers/root_controller_test.exs | 4 +- .../controllers/session_controller_test.exs | 4 +- .../controllers/task_controller_test.exs | 26 +- .../controllers/task_pack_controller_test.exs | 97 ++++++- .../tournament/stream_controller_test.exs | 16 +- .../tournament_controller_test.exs | 14 +- .../tournaments_schedule_controller_test.exs | 4 +- .../controllers/user_controller_test.exs | 6 +- .../game/forbid_multiple_games_test.exs | 2 +- .../game/game_with_reporting_test.exs | 2 +- .../integration/game/give_up_test.exs | 2 +- .../integration/game/rematch_test.exs | 4 +- .../integration/game/standard_game_test.exs | 6 +- .../integration/game/timeout_test.exs | 2 +- .../live/admin/clan_index_view_test.exs | 2 +- .../admin/tournament_stream_view_test.exs | 2 +- .../live/admin/user_index_view_test.exs | 2 +- .../plugs/assign_current_user_test.exs | 36 ++- .../plugs/restrict_access_test.exs | 8 +- apps/codebattle/test/support/channel_case.ex | 6 + apps/codebattle/test/support/conn_case.ex | 12 + mix.exs | 4 + 103 files changed, 2085 insertions(+), 335 deletions(-) create mode 100644 apps/codebattle/assets/js/__tests__/GameRecovery.test.ts create mode 100644 apps/codebattle/lib/codebattle/user_session.ex create mode 100644 apps/codebattle/lib/codebattle_web/user_auth.ex delete mode 100644 apps/codebattle/lib/codebattle_web/views/admin/dev_solution_tester_view.ex delete mode 100644 apps/codebattle/lib/codebattle_web/views/group_task_view.ex delete mode 100644 apps/codebattle/lib/codebattle_web/views/raw_task_view.ex create mode 100644 apps/codebattle/priv/repo/migrations/20260723190000_create_user_sessions.exs create mode 100644 apps/codebattle/test/codebattle_web/controllers/dev_login_controller_test.exs diff --git a/AGENTS.md b/AGENTS.md index 33b0f93ea..5ee482961 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,8 @@ For frontend-only tasks in `apps/codebattle/`: - Naming: descriptive Elixir modules; `camelCase`/`PascalCase` for JS files and components. ## Testing Guidelines +- Use `make test` as the canonical final repository test command; do not substitute a direct `mix test` run. +- Run `mix credo --strict` and `mix dialyzer` as part of the final repository verification. - ExUnit tests live in `apps/*/test/`; coverage uses ExCoveralls with a 60% threshold. - Frontend tests use Jest in `apps/codebattle/`. - Name tests after the module/component under test (e.g., `user_stats_test.exs`, `UserStats.test.jsx`). diff --git a/apps/codebattle/assets/js/__tests__/GameRecovery.test.ts b/apps/codebattle/assets/js/__tests__/GameRecovery.test.ts new file mode 100644 index 000000000..9c2ed928b --- /dev/null +++ b/apps/codebattle/assets/js/__tests__/GameRecovery.test.ts @@ -0,0 +1,30 @@ +import machines from '../widgets/machines'; +import { findCurrentUserPlayingGame } from '../widgets/middlewares/Lobby'; + +describe('game recovery flows', () => { + test('opens and closes a loaded replay on the first event', () => { + const machine = machines.game.withContext({ + ...machines.game.context, + subscriptionType: 'premium', + }); + + const loading = machine.transition(machine.initialState, 'START_LOADING_PLAYBOOK'); + expect(loading.matches({ replayer: 'loading' })).toBe(true); + + const opened = machine.transition(loading, { type: 'LOAD_PLAYBOOK', payload: {} }); + expect(opened.matches({ replayer: 'on' })).toBe(true); + + const closed = machine.transition(opened, 'CLOSE_REPLAYER'); + expect(closed.matches({ replayer: 'off' })).toBe(true); + }); + + test('recovers a playing game from a lobby channel snapshot', () => { + const games = [ + { id: 10, state: 'waiting_opponent', players: [{ id: 7 }] }, + { id: 11, state: 'playing', players: [{ id: 7 }, { id: 8 }] }, + ]; + + expect(findCurrentUserPlayingGame(games, 7)?.id).toBe(11); + expect(findCurrentUserPlayingGame(games, 9)).toBeUndefined(); + }); +}); diff --git a/apps/codebattle/assets/js/__tests__/UserSettings.test.tsx b/apps/codebattle/assets/js/__tests__/UserSettings.test.tsx index 09aa8a57f..19b518087 100644 --- a/apps/codebattle/assets/js/__tests__/UserSettings.test.tsx +++ b/apps/codebattle/assets/js/__tests__/UserSettings.test.tsx @@ -13,7 +13,9 @@ vi.mock('@fortawesome/react-fontawesome', () => ({ })); vi.mock('calcite-react/Slider', () => ({ default: 'input' })); -vi.mock('../widgets/components/LanguageIcon', () => ({ default: () => null })); +vi.mock('../widgets/components/LanguageIcon', () => ({ + default: ({ lang }: { lang?: string }) => , +})); vi.mock('react-bootstrap/Alert', () => ({ __esModule: true, default: ({ @@ -37,7 +39,12 @@ vi.mock('../i18n', () => ({ getLocale: vi.fn(() => 'en'), getSupportedLocale: vi.fn((locale) => (['en', 'ru'].includes(locale) ? locale : 'en')), default: { - t: vi.fn((key) => key), + t: vi.fn((key: string, params: Record = {}) => + Object.entries(params).reduce( + (result, [name, value]) => result.replace(`%{${name}}`, String(value)), + key, + ), + ), changeLanguage: vi.fn(() => Promise.resolve()), }, })); @@ -72,6 +79,24 @@ vi.mock('@/inertia/pageProps', () => { local: 'en', current_user: { sound_settings: {} }, game_id: 10, + user_sessions: [ + { + id: 'current-session', + current: true, + user_agent: 'Current browser', + ip: '127.0.0.1', + last_seen_at: '2026-07-23T12:00:00Z', + created_at: '2026-07-23T11:00:00Z', + }, + { + id: 'other-session', + current: false, + user_agent: 'Other browser', + ip: '10.0.0.2', + last_seen_at: '2026-07-22T12:00:00Z', + created_at: '2026-07-22T11:00:00Z', + }, + ], }; return { getPageProp: (key: keyof typeof pageProps, fallback?: unknown) => pageProps[key] ?? fallback, @@ -107,7 +132,7 @@ describe('UserSettings test cases', () => { ok: true, json: async () => ({}), }); - const { getByRole, getByLabelText, getByTestId, user } = setup( + const { getByRole, getByLabelText, getByTestId, findByText, user } = setup( , @@ -118,7 +143,8 @@ describe('UserSettings test cases', () => { await user.clear(nameInput); await user.type(nameInput, 'Dmitry'); - await user.selectOptions(codeLangSelect, 'Javascript'); + await user.click(codeLangSelect); + await user.click(await findByText('Javascript')); await user.click(submitButton); await waitFor(() => { @@ -228,6 +254,112 @@ describe('UserSettings test cases', () => { expect(await findByRole('alert')).toHaveClass('alert-danger'); }); + test('enables saving the Silent sound option', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ locale: 'en' }), + }); + + const { getByLabelText, user } = setup( + + + , + ); + + const submitButton = getByLabelText('SubmitForm'); + await user.click(getByLabelText('Silent')); + + expect(submitButton).toBeEnabled(); + await user.click(submitButton); + + await waitFor(() => { + const [, requestOptions] = fetchMock.mock.calls[0]; + expect(JSON.parse(requestOptions.body).sound_settings.type).toBe('silent'); + }); + }); + + test('shows language icons in the settings language menu', async () => { + const { getByTestId, getAllByTestId, user } = setup( + + + , + ); + + await user.click(getByTestId('code-langSelect')); + + expect(getAllByTestId('language-icon-js').length).toBeGreaterThan(0); + expect(getAllByTestId('language-icon-python').length).toBeGreaterThan(0); + }); + + test('changes a password without sending password fields to the settings endpoint', async () => { + const passwordStore = configureStore({ + reducer, + preloadedState: { + user: { + settings: { + ...preloadedState.user.settings, + hasPassword: true, + }, + }, + } as never, + }); + + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ locale: 'en' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ status: 'ok', has_password: true }), + }); + + const { getByLabelText, getByTestId, user } = setup( + + + , + ); + + await user.type(getByTestId('currentPasswordInput'), 'old-password-secure!'); + await user.type(getByTestId('passwordInput'), 'new-password-secure!'); + await user.type(getByTestId('passwordConfirmationInput'), 'new-password-secure!'); + await user.click(getByLabelText('SubmitForm')); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + const settingsBody = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(settingsBody).not.toHaveProperty('current_password'); + expect(settingsBody).not.toHaveProperty('password'); + expect(fetchMock.mock.calls[1][0]).toBe('/api/v1/settings/password'); + expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ + current_password: 'old-password-secure!', + password: 'new-password-secure!', + password_confirmation: 'new-password-secure!', + }); + }); + + test('removes another active device', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ status: 'ok', current: false }), + }); + + const { getByLabelText, queryByText, user } = setup( + + + , + ); + + expect(queryByText('Other browser')).toBeInTheDocument(); + await user.click(getByLabelText('Remove device Other browser')); + + await waitFor(() => expect(queryByText('Other browser')).not.toBeInTheDocument()); + expect(fetchMock).toHaveBeenCalledWith( + '/api/v1/settings/sessions/other-session', + expect.objectContaining({ method: 'DELETE' }), + ); + }); + test('unlink button is disabled when it is the last sign-in method', () => { const customStore = configureStore({ reducer, diff --git a/apps/codebattle/assets/js/widgets/middlewares/Invite.ts b/apps/codebattle/assets/js/widgets/middlewares/Invite.ts index ce69bbd24..0f3689aac 100644 --- a/apps/codebattle/assets/js/widgets/middlewares/Invite.ts +++ b/apps/codebattle/assets/js/widgets/middlewares/Invite.ts @@ -11,8 +11,8 @@ const channel = new Channel('invites'); const camelizeKeysAndDispatch = (dispatch: any, actionCreator: any) => (data: any) => dispatch(actionCreator(camelizeKeys(data))); -const getRecipientName = (data: any) => data.invite.recipient.name; -const getCreatorName = (data: any) => data.invite.creator.name; +const getRecipientName = (data: any) => data.invite.recipient?.name || 'Anonymous'; +const getCreatorName = (data: any) => data.invite.creator?.name || 'Anonymous'; const getOpponentName = (data: any, userId: number) => { if (userId === data.invite.creatorId) { return getRecipientName(data); @@ -120,8 +120,10 @@ export const acceptInvite = (id: number) => (dispatch: any) => dispatch(actions.updateInvite(data)); }) - .receive('error', ({ reason }) => { - dispatch(actions.updateInvite({ id, state: 'invalid' } as any)); + .receive('error', ({ reason, invite }) => { + dispatch( + actions.updateInvite(invite ? camelizeKeys({ invite }) : ({ id, state: 'invalid' } as any)), + ); throw new Error(reason); }); diff --git a/apps/codebattle/assets/js/widgets/middlewares/Lobby.ts b/apps/codebattle/assets/js/widgets/middlewares/Lobby.ts index 6d35b1f07..9c142fd6f 100644 --- a/apps/codebattle/assets/js/widgets/middlewares/Lobby.ts +++ b/apps/codebattle/assets/js/widgets/middlewares/Lobby.ts @@ -10,23 +10,39 @@ import Channel from './Channel'; const channel = new Channel(); -export const fetchState = (currentUserId: number) => (dispatch: any) => { +export const findCurrentUserPlayingGame = (games: any[] = [], currentUserId: number) => + games.find( + (game: any) => + game.state === 'playing' && + some(game.players, ({ id: playerId }: { id: number }) => playerId === currentUserId), + ); + +export const fetchState = (currentUserId: number, waitingGameId?: number) => (dispatch: any) => { const channelName = 'lobby'; channel.setupChannel(channelName); const camelizeKeysAndDispatch = (actionCreator: any) => (data: any) => dispatch(actionCreator(camelizeKeys(data))); - channel.join().receive('ok', camelizeKeysAndDispatch(actions.initGameList)); + channel.join().receive('ok', (data: any) => { + const normalizedData = camelizeKeys(data); + dispatch(actions.initGameList(normalizedData)); + + const activeGame = findCurrentUserPlayingGame(normalizedData.activeGames, currentUserId); + if (activeGame?.id === waitingGameId) { + window.location.replace(`/games/${activeGame.id}`); + } + }); channel.onError(() => { dispatch(actions.updateLobbyChannelState(false)); }); const handleGameUpsert = (data: any) => { + const normalizedData = camelizeKeys(data); const { game: { players, id, state: gameState }, - } = data; + } = normalizedData; const currentPlayerId = currentUserId; const isGameStarted = gameState === 'playing'; const isCurrentUserInGame = some( @@ -35,9 +51,9 @@ export const fetchState = (currentUserId: number) => (dispatch: any) => { ); if (isGameStarted && isCurrentUserInGame) { - window.location.href = `/games/${id}`; + window.location.replace(`/games/${id}`); } else { - dispatch(actions.upsertGameLobby(data)); + dispatch(actions.upsertGameLobby(normalizedData)); } }; diff --git a/apps/codebattle/assets/js/widgets/pages/game/ActionsAfterGame.tsx b/apps/codebattle/assets/js/widgets/pages/game/ActionsAfterGame.tsx index 37f3ca9c8..eb4ecf78a 100755 --- a/apps/codebattle/assets/js/widgets/pages/game/ActionsAfterGame.tsx +++ b/apps/codebattle/assets/js/widgets/pages/game/ActionsAfterGame.tsx @@ -13,10 +13,15 @@ function ActionsAfterGame() { const { tournamentId } = useSelector(selectors.gameStatusSelector); const gameMode = useSelector(selectors.gameModeSelector); const isOpponentInGame = useSelector(selectors.isOpponentInGameSelector); + const isCurrentUserGuest = useSelector(selectors.currentUserIsGuestSelector); const isRematchDisabled = !isOpponentInGame; if (gameMode === GameRoomModes.training) { + if (!isCurrentUserGuest) { + return null; + } + return ( <> diff --git a/apps/codebattle/assets/js/widgets/pages/lobby/LobbyWidget.tsx b/apps/codebattle/assets/js/widgets/pages/lobby/LobbyWidget.tsx index a9e0aa4cd..31cd300e2 100644 --- a/apps/codebattle/assets/js/widgets/pages/lobby/LobbyWidget.tsx +++ b/apps/codebattle/assets/js/widgets/pages/lobby/LobbyWidget.tsx @@ -167,7 +167,8 @@ function LobbyWidget() { ); useEffect(() => { - const channel = lobbyMiddlewares.fetchState(currentUserId ?? 0)(dispatch); + const waitingGameId = activeGame?.state === 'waiting_opponent' ? activeGame.id : undefined; + const channel = lobbyMiddlewares.fetchState(currentUserId ?? 0, waitingGameId)(dispatch); if (currentOpponent) { window.history.replaceState({}, document.title, getLobbyUrl()); diff --git a/apps/codebattle/assets/js/widgets/pages/lobby/TaskChoice.tsx b/apps/codebattle/assets/js/widgets/pages/lobby/TaskChoice.tsx index ff2886002..3d3dd474d 100644 --- a/apps/codebattle/assets/js/widgets/pages/lobby/TaskChoice.tsx +++ b/apps/codebattle/assets/js/widgets/pages/lobby/TaskChoice.tsx @@ -25,6 +25,8 @@ const taskTags = getPageProp('task_tags', []); interface Task { id: number | null; name?: string; + descriptionEn?: string; + descriptionRu?: string; level?: string; origin?: string; creatorId?: number; @@ -94,8 +96,22 @@ function TaskSelect({ value, onChange, options }: TaskSelectProps) { random: { icon: faShuffle, transform: 'down-1' }, }; + const getLocalizedDescription = (task: Task) => { + const description = + i18n.language === 'ru' + ? task.descriptionRu || task.descriptionEn + : task.descriptionEn || task.descriptionRu; + + return description + ?.split('\n') + .map((line) => line.replace(/^#+\s*/, '').trim()) + .find(Boolean); + }; + const renderOptionLabel = (task: Task) => { const origin = taskOriginToIcon[task.origin ?? 'random'] ?? taskOriginToIcon.random; + const description = getLocalizedDescription(task); + const title = description || task.name; return (
@@ -110,7 +126,9 @@ function TaskSelect({ value, onChange, options }: TaskSelectProps) { // eslint-disable-next-line @typescript-eslint/no-explicit-any )} - {task.name} + + {title} +
); }; @@ -185,9 +203,13 @@ function TaskSelect({ value, onChange, options }: TaskSelectProps) { value={value} onChange={(task) => onChange(task as Task)} options={options} - getOptionLabel={(task) => renderOptionLabel(task) as unknown as string} + getOptionLabel={(task) => task.name ?? ''} + formatOptionLabel={renderOptionLabel} getOptionValue={(task) => String(task.id)} - filterOption={createFilter({ stringify: (option) => option.data.name ?? '' })} + filterOption={createFilter({ + stringify: (option) => + [option.data.name, getLocalizedDescription(option.data)].filter(Boolean).join(' '), + })} /> ); } diff --git a/apps/codebattle/assets/js/widgets/pages/schedule/TournamentSchedule.tsx b/apps/codebattle/assets/js/widgets/pages/schedule/TournamentSchedule.tsx index 275687157..d83fd47bc 100644 --- a/apps/codebattle/assets/js/widgets/pages/schedule/TournamentSchedule.tsx +++ b/apps/codebattle/assets/js/widgets/pages/schedule/TournamentSchedule.tsx @@ -9,7 +9,7 @@ import { useSelector } from 'react-redux'; import { grades } from '@/config/grades'; import ModalCodes from '@/config/modalCodes'; import { uploadFinishedTournaments, uploadTournamentsByFilter } from '@/middlewares/Tournament'; -import { currentUserIdSelector, currentUserIsAdminSelector } from '@/selectors'; +import { currentUserIsAdminSelector } from '@/selectors'; import useTournamentScheduleModals from '@/utils/useTournamentScheduleModals'; import dayjs from '../../../i18n/dayjs'; @@ -53,9 +53,6 @@ interface TournamentsState { const haveSeasonGrade = (t: Tournament) => t.grade !== grades.open; const notCancelled = (t: Tournament) => t.state !== 'canceled'; -const filterMyTournaments = (userId: number | string | null) => (t: Tournament) => - t.creatorId === userId; - const getEndOffsetParams = (t: Tournament): [number, dayjs.ManipulateType] => { if (t.finished && t.lastRoundEndedAt) { const begin = dayjs(t.startsAt); @@ -133,7 +130,6 @@ function TournamentSchedule() { const [date, setDate] = useState(dayjs().format()); const [view, setView] = useState(views.month); const isAdmin = useSelector(currentUserIsAdminSelector); - const currentUserId = useSelector(currentUserIdSelector); useTournamentScheduleModals(); @@ -217,7 +213,6 @@ function TournamentSchedule() { setEvents(newEvents); } else if (context === states.my) { const newEvents = tournaments.userTournaments - .filter(filterMyTournaments(currentUserId)) .filter(notCancelled) .map(getEventFromTournamentData); @@ -232,7 +227,7 @@ function TournamentSchedule() { setEvents(newEvents); } - }, [context, currentUserId, tournaments, isAdmin]); + }, [context, tournaments, isAdmin]); useEffect(() => { if (context !== states.list || historyLoaded) { diff --git a/apps/codebattle/assets/js/widgets/pages/settings/UserSettings.tsx b/apps/codebattle/assets/js/widgets/pages/settings/UserSettings.tsx index b7d7325ca..dbdf5a738 100644 --- a/apps/codebattle/assets/js/widgets/pages/settings/UserSettings.tsx +++ b/apps/codebattle/assets/js/widgets/pages/settings/UserSettings.tsx @@ -7,8 +7,10 @@ import capitalize from 'lodash/capitalize'; import noop from 'lodash/noop'; import Alert from 'react-bootstrap/Alert'; import { useDispatch, useSelector } from 'react-redux'; +import type { FormikHelpers } from 'formik'; import type { RootState } from '@/slices/store'; +import { getPageProp } from '@/inertia/pageProps'; import i18n, { getSupportedLocale } from '../../../i18n'; import { userSettingsSelector } from '../../selectors'; @@ -25,7 +27,16 @@ interface Notification { } interface UpdateSettingsError extends Error { - response?: { data: { errors: { name?: string[] } }; status: number }; + response?: { data: { errors: Record }; status: number }; +} + +interface UserSessionData { + id: string; + current: boolean; + userAgent?: string | null; + ip?: string | null; + lastSeenAt: string; + createdAt: string; } const providers = ['github', 'discord'] as const; @@ -60,6 +71,55 @@ const updateSettings = async (values: Record) => { return data; }; +const updatePassword = async (values: Record) => { + const response = await fetch('/api/v1/settings/password', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': csrfToken ?? '', + }, + body: JSON.stringify(decamelizeKeys(values)), + }); + const data = await response.json(); + + if (!response.ok) { + const error = new Error(`Request failed with status ${response.status}`) as UpdateSettingsError; + error.response = { data, status: response.status }; + throw error; + } + + return data; +}; + +const deleteUserSession = async (sessionId: string) => { + const response = await fetch(`/api/v1/settings/sessions/${sessionId}`, { + method: 'DELETE', + headers: { + 'x-csrf-token': csrfToken ?? '', + }, + }); + const data = await response.json(); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + return data as { current: boolean }; +}; + +const formatFieldErrors = (errors: Record = {}) => + Object.entries(camelizeKeys(errors)).reduce>( + (result, [fieldName, messages]) => { + const fieldMessages = Array.isArray(messages) ? messages : [String(messages)]; + + return { + ...result, + [fieldName]: fieldMessages.map(capitalize).join(', '), + }; + }, + {}, + ); + interface NotificationProps { notification: Notification; onClose: (notification: Notification) => void; @@ -108,11 +168,11 @@ function SocialButtons({ settings }: SocialButtonsProps) { data-to={`/auth/${provider}`} disabled={!settings.canUnlinkSocial} > - {`Unlink ${formatedProviderName}`} + {i18n.t('Unlink %{provider}', { provider: formatedProviderName })} ) : ( - {`Link ${formatedProviderName}`} + {i18n.t('Link %{provider}', { provider: formatedProviderName })} )} @@ -122,21 +182,63 @@ function SocialButtons({ settings }: SocialButtonsProps) { function UserSettings() { const [notification, setNotification] = useState(notifications.empty); + const [sessions, setSessions] = useState(() => + camelizeKeys(getPageProp('user_sessions', [])), + ); + const [revokingSessionId, setRevokingSessionId] = useState(); const settings = useSelector((state: RootState) => userSettingsSelector(state), ) as UserSettingsData; const dispatch = useDispatch(); + const handleDeleteSession = useCallback(async (session: UserSessionData) => { + setRevokingSessionId(session.id); + + try { + const result = await deleteUserSession(session.id); + + if (result.current) { + window.location.assign('/session/new'); + return; + } + + setSessions((currentSessions) => + currentSessions.filter((currentSession) => currentSession.id !== session.id), + ); + setNotification(notifications.success); + } catch { + setNotification(notifications.error); + } finally { + setRevokingSessionId(undefined); + } + }, []); + const handleUpdateUserSettings = useCallback( async ( values: UserSettingsFormValues, - { setErrors }: { setErrors: (errors: { name?: string }) => void }, + { setErrors, resetForm }: FormikHelpers, ) => { + const { currentPassword, password, passwordConfirmation, ...settingsValues } = values; + const passwordValues = { currentPassword, password, passwordConfirmation }; + try { - const data = await updateSettings(values as unknown as Record); + const data = await updateSettings(settingsValues as unknown as Record); await i18n.changeLanguage(getSupportedLocale(data.locale)); dispatch(actions.updateUserSettings(camelizeKeys(data))); + + if (Object.values(passwordValues).some((value) => value.trim())) { + await updatePassword(passwordValues); + } + + resetForm({ + values: { + ...settingsValues, + currentPassword: '', + password: '', + passwordConfirmation: '', + }, + }); setNotification(notifications.success); } catch (rawError) { const error = rawError as UpdateSettingsError; @@ -145,8 +247,7 @@ function UserSettings() { return; } - const { name: userNameErrors = [] } = error.response.data.errors; - setErrors({ name: userNameErrors.map(capitalize).join(', ') }); + setErrors(formatFieldErrors(error.response.data.errors)); } }, [dispatch], @@ -155,12 +256,52 @@ function UserSettings() { return (
-

Settings

+

{i18n.t('Settings')}

-

Socials

+

{i18n.t('Socials')}

+
+

{i18n.t('Active devices')}

+ {sessions.length === 0 ? ( +
{i18n.t('No active devices')}
+ ) : ( +
+ {sessions.map((session) => ( +
+
+
+ {session.userAgent || i18n.t('Unknown device')} + {session.current && ( + {i18n.t('Current device')} + )} +
+ + {[session.ip, new Date(session.lastSeenAt).toLocaleString()] + .filter(Boolean) + .join(' · ')} + +
+ +
+ ))} +
+ )} +
); } diff --git a/apps/codebattle/assets/js/widgets/pages/settings/UserSettingsForm.tsx b/apps/codebattle/assets/js/widgets/pages/settings/UserSettingsForm.tsx index 0a0bc0757..1fb612fcf 100644 --- a/apps/codebattle/assets/js/widgets/pages/settings/UserSettingsForm.tsx +++ b/apps/codebattle/assets/js/widgets/pages/settings/UserSettingsForm.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import cn from 'classnames'; -import { Field, Form, Formik, useField } from 'formik'; +import { Field, Form, Formik, useField, type FormikHelpers } from 'formik'; import capitalize from 'lodash/capitalize'; import omit from 'lodash/omit'; import pick from 'lodash/pick'; @@ -11,6 +11,7 @@ import * as Yup from 'yup'; import LanguageIcon from '@/components/LanguageIcon'; +import i18n from '../../../i18n'; import languages, { cssProcessors, dbNames } from '../../config/languages'; import schemas from '../../formik'; import { createPlayer } from '../../lib/sound'; @@ -30,6 +31,7 @@ export interface UserSettingsData { githubId?: string | number | null; discordId?: string | number | null; canUnlinkSocial?: boolean; + hasPassword?: boolean; [key: string]: unknown; } @@ -46,6 +48,9 @@ export interface UserSettingsFormValues { level: number; tournamentLevel: number; }; + currentPassword: string; + password: string; + passwordConfirmation: string; } type SoundType = 'dendy' | 'cs' | 'standard'; @@ -57,6 +62,38 @@ const views = { } as const; type SettingsView = (typeof views)[keyof typeof views]; +const passwordFieldNames = ['currentPassword', 'password', 'passwordConfirmation'] as const; + +const hasPasswordValue = (values: Partial) => + passwordFieldNames.some((fieldName) => Boolean(values[fieldName]?.trim())); + +const passwordValidationSchema = { + currentPassword: Yup.string().test( + 'required-current-password', + "Field can't be empty", + function (value) { + return !hasPasswordValue(this.parent) || Boolean(value?.trim()); + }, + ), + password: Yup.string() + .test('required-password', "Field can't be empty", function (value) { + return !hasPasswordValue(this.parent) || Boolean(value?.trim()); + }) + .test('password-min-length', 'Should be at least 12 characters', function (value) { + return !hasPasswordValue(this.parent) || (value?.length ?? 0) >= 12; + }) + .test('password-max-bytes', 'Should be at most 72 bytes', function (value) { + return !value || new TextEncoder().encode(value).length <= 72; + }) + .matches(/^\S*$/, "Can't contain empty symbols"), + passwordConfirmation: Yup.string() + .test('required-password-confirmation', "Field can't be empty", function (value) { + return !hasPasswordValue(this.parent) || Boolean(value?.trim()); + }) + .test('password-confirmation', 'Passwords must match', function (value) { + return !hasPasswordValue(this.parent) || value === this.parent.password; + }), +}; const playingLanguages = Object.entries(omit(languages, [...cssProcessors, ...dbNames])); const cssLanguages = Object.entries(pick(languages, cssProcessors)); @@ -103,7 +140,7 @@ const getPlaceholder = ({ return placeholder; } - return 'No access yet'; + return i18n.t('No access yet'); }; function TextInput({ label, ...props }: TextInputProps) { @@ -143,26 +180,48 @@ interface LanguageSelectProps { } function LanguageSelect({ lang, view, currentView, items }: LanguageSelectProps) { + const [field, , helpers] = useField(getFieldNameByView(view)); + const selectedSlug = field.value || lang || items[0]?.[0]; + const selectedName = items.find(([slug]) => slug === selectedSlug)?.[1] || selectedSlug; + return (
-
Your weapon
+
{i18n.t('Your weapon')}
-
- - + - {items.map(([slug, l]) => ( - + + {capitalize(selectedName)} + + + {items.map(([slug, languageName]) => ( + helpers.setValue(slug)} + > + + {capitalize(languageName)} + ))} - -
+ +
); @@ -240,7 +299,7 @@ interface UserSettingsFormProps { settings: UserSettingsData; onSubmit: ( values: UserSettingsFormValues, - formikHelpers: { setErrors: (errors: { name?: string }) => void }, + formikHelpers: FormikHelpers, ) => void | Promise; } @@ -259,11 +318,17 @@ function UserSettingsForm({ onSubmit, settings }: UserSettingsFormProps) { lang: settings.lang || '', styleLang: settings.styleLang || '', dbType: settings.dbType || '', + currentPassword: '', + password: '', + passwordConfirmation: '', }), [settings], ); - const validationSchema = useMemo(() => Yup.object(schemas.userSettings(settings)), [settings]); + const validationSchema = useMemo( + () => Yup.object({ ...schemas.userSettings(settings), ...passwordValidationSchema }), + [settings], + ); return (
-
Locale
+
{i18n.t('Locale')}
@@ -329,8 +394,45 @@ function UserSettingsForm({ onSubmit, settings }: UserSettingsFormProps) { + {settings.hasPassword && ( +
+
+
+

{i18n.t('Change password')}

+ + + +
+
+
+ )} +
- Select sound type + {i18n.t('Select sound type')}
@@ -343,7 +445,7 @@ function UserSettingsForm({ onSubmit, settings }: UserSettingsFormProps) { onClick={() => playSound('dendy', values.soundSettings.level * 0.1)} />
@@ -356,7 +458,7 @@ function UserSettingsForm({ onSubmit, settings }: UserSettingsFormProps) { onClick={() => playSound('cs', values.soundSettings.level * 0.1)} />
@@ -369,7 +471,7 @@ function UserSettingsForm({ onSubmit, settings }: UserSettingsFormProps) { onClick={() => playSound('standard', values.soundSettings.level * 0.1)} />
@@ -381,12 +483,12 @@ function UserSettingsForm({ onSubmit, settings }: UserSettingsFormProps) { className="form-check-input" />
-
Select sound level
+
{i18n.t('Select sound level')}
-
Select tournament sound level
+
{i18n.t('Select tournament sound level')}
Loading...
) : ( - 'Save' + i18n.t('Save') )} diff --git a/apps/codebattle/assets/js/widgets/pages/tournament/PlayersRankingPanel.tsx b/apps/codebattle/assets/js/widgets/pages/tournament/PlayersRankingPanel.tsx index d88755ad6..9be7e4f6c 100644 --- a/apps/codebattle/assets/js/widgets/pages/tournament/PlayersRankingPanel.tsx +++ b/apps/codebattle/assets/js/widgets/pages/tournament/PlayersRankingPanel.tsx @@ -160,7 +160,7 @@ function PlayersRankingPanel({ }; const handleKickPlayer = (player: RankingItem) => { - if (!window.confirm(i18next.t('Kick {{name}} from tournament?', { name: player.name }))) { + if (!window.confirm(i18next.t('Kick %{name} from tournament?', { name: player.name }))) { return; } diff --git a/apps/codebattle/lib/codebattle/application.ex b/apps/codebattle/lib/codebattle/application.ex index eba6f0dd4..ad69465fb 100644 --- a/apps/codebattle/lib/codebattle/application.ex +++ b/apps/codebattle/lib/codebattle/application.ex @@ -20,6 +20,10 @@ defmodule Codebattle.Application do {ChromicPDF, chromic_pdf_opts()}, Supervisor.child_spec({Cachex, name: :season_cache}, id: :season_cache), Supervisor.child_spec({Cachex, name: :github_stats_cache}, id: :github_stats_cache), + Supervisor.child_spec( + {Cachex, name: :password_attempts_cache}, + id: :password_attempts_cache + ), {Codebattle.UsersPointsAndRankServer, []}, {Codebattle.UserAchievementsServer, []}, {Codebattle.Bot.GameCreator, []}, diff --git a/apps/codebattle/lib/codebattle/bot/server.ex b/apps/codebattle/lib/codebattle/bot/server.ex index d762fe7a7..464452357 100644 --- a/apps/codebattle/lib/codebattle/bot/server.ex +++ b/apps/codebattle/lib/codebattle/bot/server.ex @@ -259,7 +259,9 @@ defmodule Codebattle.Bot.Server do end defp bot_token(bot_id) do - Phoenix.Token.sign(%Phoenix.Socket{endpoint: CodebattleWeb.Endpoint}, "user_token", bot_id) + bot = Codebattle.User.get!(bot_id) + {:ok, token} = Codebattle.UserSession.create_socket_token(bot, %{user_agent: "Codebattle bot"}) + token end defp join_channel(socket, topic), do: do_join_channel(socket, topic, 0) diff --git a/apps/codebattle/lib/codebattle/forms/task_pack.ex b/apps/codebattle/lib/codebattle/forms/task_pack.ex index 0cbb8a4ba..31ed93545 100644 --- a/apps/codebattle/lib/codebattle/forms/task_pack.ex +++ b/apps/codebattle/lib/codebattle/forms/task_pack.ex @@ -6,6 +6,9 @@ defmodule Codebattle.TaskPackForm do alias Codebattle.Repo alias Codebattle.TaskPack + @task_id_range 1..2_147_483_647 + @max_task_ids 10_000 + def create(params, user) do new_params = Map.merge(params, %{"state" => "draft", "creator_id" => user.id}) @@ -44,16 +47,41 @@ defmodule Codebattle.TaskPackForm do end defp cast_task_ids(changeset, params) do - task_ids = - params - |> Map.get("task_ids", "") - |> String.split(",") - |> Enum.map(&String.trim/1) - |> Enum.map(&String.to_integer/1) - - put_change(changeset, :task_ids, task_ids) - rescue - _ -> + raw_task_ids = Map.get(params, "task_ids", "") + + if is_binary(raw_task_ids) do + cast_binary_task_ids(changeset, raw_task_ids) + else add_error(changeset, :task_ids, "Please provide only integers with comma separated values") + end + end + + defp cast_binary_task_ids(changeset, raw_task_ids) do + tokens = String.split(raw_task_ids, ",", trim: false) + + cond do + length(tokens) > @max_task_ids -> + add_error(changeset, :task_ids, "Please provide no more than #{@max_task_ids} task ids") + + Enum.any?(tokens, &(not Regex.match?(~r/^\s*\d+\s*$/, &1))) -> + add_error(changeset, :task_ids, "Please provide only integers with comma separated values") + + true -> + cast_parsed_task_ids(changeset, tokens) + end + end + + defp cast_parsed_task_ids(changeset, tokens) do + task_ids = Enum.map(tokens, &(&1 |> String.trim() |> String.to_integer())) + + if Enum.all?(task_ids, &(&1 in @task_id_range)) do + put_change(changeset, :task_ids, task_ids) + else + add_error( + changeset, + :task_ids, + "Please provide integers between 1 and 2147483647 with comma separated values" + ) + end end end diff --git a/apps/codebattle/lib/codebattle/invite.ex b/apps/codebattle/lib/codebattle/invite.ex index 7d2e54043..1f4c9f893 100644 --- a/apps/codebattle/lib/codebattle/invite.ex +++ b/apps/codebattle/lib/codebattle/invite.ex @@ -143,6 +143,14 @@ defmodule Codebattle.Invite do raise "Not authorized!" end + if invite.state == "pending" do + do_accept_invite(invite) + else + {:error, :invite_is_not_pending} + end + end + + defp do_accept_invite(invite) do users = [invite.creator, invite.recipient] game_params = %{ @@ -174,10 +182,19 @@ defmodule Codebattle.Invite do {:ok, %{invite: invite, dropped_invites: dropped_invites}} {:error, reason} -> - {:error, reason} + maybe_drop_unavailable_invite(invite, reason) end end + defp maybe_drop_unavailable_invite(invite, :already_in_a_game = reason) do + case Invite.update_invite(invite, %{state: "dropped"}) do + {:ok, dropped_invite} -> {:error, reason, dropped_invite} + {:error, changeset} -> {:error, changeset} + end + end + + defp maybe_drop_unavailable_invite(_invite, reason), do: {:error, reason} + def cancel_invite(params) do user_id = params.user_id invite_id = params.id || raise "Not found!" diff --git a/apps/codebattle/lib/codebattle/tournament/context.ex b/apps/codebattle/lib/codebattle/tournament/context.ex index fb1565996..0171d1e6f 100644 --- a/apps/codebattle/lib/codebattle/tournament/context.ex +++ b/apps/codebattle/lib/codebattle/tournament/context.ex @@ -228,7 +228,12 @@ defmodule Codebattle.Tournament.Context do t.starts_at <= ^datetime_to and (t.creator_id == ^user_id or fragment("? = ANY(?)", ^user_id, t.winner_ids) or - fragment("? = ANY(?)", ^user_id, t.moderator_ids)) + fragment("? = ANY(?)", ^user_id, t.moderator_ids) or + fragment( + "((? #>> '{}')::jsonb) \\? ?", + t.players, + ^Integer.to_string(user_id) + )) ) ) end diff --git a/apps/codebattle/lib/codebattle/user.ex b/apps/codebattle/lib/codebattle/user.ex index 9a14dcc0a..9cdc271a6 100644 --- a/apps/codebattle/lib/codebattle/user.ex +++ b/apps/codebattle/lib/codebattle/user.ex @@ -10,6 +10,7 @@ defmodule Codebattle.User do alias Codebattle.Clan alias Codebattle.Repo alias Codebattle.User.SoundSettings + alias Codebattle.UserSession @type t :: %__MODULE__{} @type raw_id :: String.t() | integer() @@ -52,6 +53,7 @@ defmodule Codebattle.User do has_many(:user_games, Codebattle.UserGame) has_many(:games, through: [:user_games, :game]) has_many(:achievements, Codebattle.UserAchievement) + has_many(:sessions, UserSession) field(:auth_token, :string) field(:avatar_url, :string) @@ -87,7 +89,10 @@ defmodule Codebattle.User do field(:timezone, :string, default: "Etc/UTC") field(:games_played, :integer, virtual: true) + field(:current_password, :string, virtual: true) field(:is_guest, :boolean, virtual: true, default: false) + field(:password, :string, virtual: true) + field(:password_confirmation, :string, virtual: true) embeds_one(:sound_settings, SoundSettings, on_replace: :update) @@ -149,6 +154,49 @@ defmodule Codebattle.User do |> assign_clan(params, user.id) end + def password_changeset(user, params \\ %{}) do + user + |> cast(params, [:current_password, :password, :password_confirmation]) + |> validate_required([:current_password, :password, :password_confirmation]) + |> validate_length(:password, min: 12, message: "should be at least 12 character(s)") + |> validate_password_byte_length() + |> validate_confirmation(:password) + |> validate_current_password() + |> put_password_hash() + end + + def update_password(user, current_session, params, session_attrs) do + fn -> + with {:user, {:ok, updated_user}} <- + {:user, Repo.update(password_changeset(user, params))}, + {:sessions, {:ok, session_data}} <- + {:sessions, + UserSession.renew_current_and_revoke_others( + Repo, + updated_user, + current_session, + session_attrs + )} do + {updated_user, session_data} + else + {:user, {:error, changeset}} -> Repo.rollback({:user, changeset}) + {:sessions, {:error, reason}} -> Repo.rollback({:sessions, reason}) + end + end + |> Repo.transaction() + |> case do + {:ok, {updated_user, session_data}} -> + UserSession.disconnect_many(session_data.revoked_session_ids) + {:ok, updated_user, session_data} + + {:error, {:user, %Ecto.Changeset{} = changeset}} -> + {:error, changeset} + + {:error, {:sessions, reason}} -> + {:error, reason} + end + end + def token_changeset(user, params \\ %{}) do user |> cast(params, [ @@ -315,9 +363,13 @@ defmodule Codebattle.User do end end - defp verify_password(_user, nil), do: nil + def has_password?(user), do: present?(user.password_hash) + + def verify_password(_user, nil), do: nil + + def verify_password(%__MODULE__{password_hash: password_hash}, _password) when password_hash in [nil, ""], do: nil - defp verify_password(user, password) do + def verify_password(user, password) do if Bcrypt.verify_pass(password, user.password_hash) do user end @@ -332,9 +384,13 @@ defmodule Codebattle.User do def create_password_hash(user, password) do hashed_password = Bcrypt.hash_pwd_salt(password) - Repo.update_all(from(u in __MODULE__, where: u.id == ^user.id), - set: [password_hash: hashed_password] - ) + result = + Repo.update_all(from(u in __MODULE__, where: u.id == ^user.id), + set: [password_hash: hashed_password] + ) + + UserSession.revoke_all(user.id) + result end def subscription_types, do: @subscription_types @@ -353,6 +409,32 @@ defmodule Codebattle.User do defp present?(value), do: not is_nil(value) and value != "" + defp validate_password_byte_length(changeset) do + validate_change(changeset, :password, fn :password, password -> + if byte_size(password) <= 72 do + [] + else + [password: "should be at most 72 bytes"] + end + end) + end + + defp validate_current_password(%{valid?: true} = changeset) do + if verify_password(changeset.data, get_change(changeset, :current_password)) do + changeset + else + add_error(changeset, :current_password, "is invalid") + end + end + + defp validate_current_password(changeset), do: changeset + + defp put_password_hash(%{valid?: true} = changeset) do + put_change(changeset, :password_hash, Bcrypt.hash_pwd_salt(get_change(changeset, :password))) + end + + defp put_password_hash(changeset), do: changeset + defp assign_clan(changeset, %{:clan => clan}, _user_id) when clan in ["", nil], do: change(changeset, %{clan: nil, clan_id: nil}) diff --git a/apps/codebattle/lib/codebattle/user_session.ex b/apps/codebattle/lib/codebattle/user_session.ex new file mode 100644 index 000000000..d7fb671a9 --- /dev/null +++ b/apps/codebattle/lib/codebattle/user_session.ex @@ -0,0 +1,257 @@ +defmodule Codebattle.UserSession do + @moduledoc """ + Represents one revocable browser session. + + The raw session token is stored only in the signed, HTTP-only browser cookie. + The database contains its SHA-256 hash, so a database read does not expose + reusable credentials. + """ + + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Codebattle.Repo + alias Codebattle.User + alias CodebattleWeb.Endpoint + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :id + @session_lifetime_days 365 + @touch_interval_seconds 300 + @token_bytes 32 + + schema "user_sessions" do + belongs_to(:user, User) + + field(:token_hash, :binary) + field(:user_agent, :string) + field(:ip, :string) + field(:last_seen_at, :utc_datetime) + field(:expires_at, :utc_datetime) + field(:revoked_at, :utc_datetime) + + timestamps() + end + + def changeset(session, attrs) do + session + |> cast(attrs, [:user_id, :token_hash, :user_agent, :ip, :last_seen_at, :expires_at, :revoked_at]) + |> validate_required([:user_id, :token_hash, :last_seen_at, :expires_at]) + |> validate_length(:user_agent, max: 512) + |> validate_length(:ip, max: 64) + |> unique_constraint(:token_hash) + |> foreign_key_constraint(:user_id) + end + + def create(user, attrs \\ %{}) do + {token, token_hash} = generate_token() + now = now() + + attrs = + attrs + |> Map.new() + |> Map.merge(%{ + user_id: user.id, + token_hash: token_hash, + last_seen_at: now, + expires_at: DateTime.add(now, @session_lifetime_days, :day) + }) + + case %__MODULE__{} |> changeset(attrs) |> Repo.insert() do + {:ok, session} -> {:ok, session, token} + {:error, changeset} -> {:error, changeset} + end + end + + def create_socket_token(user, attrs \\ %{}) do + with {:ok, session, _token} <- create(user, attrs) do + {:ok, Phoenix.Token.sign(Endpoint, "user_token", {user.id, session.id})} + end + end + + def get_active_by_token(token) when is_binary(token) do + current_time = now() + + session = + __MODULE__ + |> where([s], s.token_hash == ^hash_token(token)) + |> where([s], is_nil(s.revoked_at) and s.expires_at > ^current_time) + |> preload(:user) + |> Repo.one() + + touch(session, current_time) + end + + def get_active_by_token(_token), do: nil + + def get_active_for_socket(user_id, session_id) do + case Ecto.UUID.cast(session_id) do + {:ok, session_id} -> + current_time = now() + + __MODULE__ + |> where([s], s.id == ^session_id and s.user_id == ^user_id) + |> where([s], is_nil(s.revoked_at) and s.expires_at > ^current_time) + |> preload(:user) + |> Repo.one() + + :error -> + nil + end + end + + def list_active(user_id) do + current_time = now() + + __MODULE__ + |> where([s], s.user_id == ^user_id) + |> where([s], is_nil(s.revoked_at) and s.expires_at > ^current_time) + |> order_by([s], desc: s.last_seen_at) + |> Repo.all() + end + + def renew_current_and_revoke_others(repo, user, current_session, attrs) do + with {:ok, session, token} <- renew_or_create(repo, user, current_session, attrs) do + revoked_session_ids = + __MODULE__ + |> where([s], s.user_id == ^user.id and s.id != ^session.id and is_nil(s.revoked_at)) + |> select([s], s.id) + |> repo.all() + + current_time = now() + + __MODULE__ + |> where([s], s.id in ^revoked_session_ids) + |> repo.update_all(set: [revoked_at: current_time, updated_at: NaiveDateTime.utc_now()]) + + {:ok, %{session: session, token: token, revoked_session_ids: revoked_session_ids}} + end + end + + def revoke_for_user(user_id, session_id) do + current_time = now() + + with {:ok, session_id} <- Ecto.UUID.cast(session_id), + %__MODULE__{} = session <- Repo.get_by(__MODULE__, id: session_id, user_id: user_id) do + session + |> change(revoked_at: current_time) + |> Repo.update() + |> case do + {:ok, revoked_session} -> + disconnect(revoked_session.id) + {:ok, revoked_session} + + error -> + error + end + else + _ -> + {:error, :not_found} + end + end + + def revoke_by_token(token) when is_binary(token) do + case get_active_by_token(token) do + nil -> + :ok + + session -> + revoke_for_user(session.user_id, session.id) + :ok + end + end + + def revoke_by_token(_token), do: :ok + + def revoke_all(user_id) do + current_time = now() + + session_ids = + __MODULE__ + |> where([s], s.user_id == ^user_id and is_nil(s.revoked_at)) + |> select([s], s.id) + |> Repo.all() + + __MODULE__ + |> where([s], s.id in ^session_ids) + |> Repo.update_all(set: [revoked_at: current_time, updated_at: NaiveDateTime.utc_now()]) + + disconnect_many(session_ids) + :ok + end + + def disconnect_many(session_ids), do: Enum.each(session_ids, &disconnect/1) + + def disconnect(session_id) do + Endpoint.broadcast("user_session:#{session_id}", "disconnect", %{}) + end + + defp renew_or_create(repo, user, nil, attrs) do + {token, token_hash} = generate_token() + current_time = now() + + attrs = + attrs + |> Map.new() + |> Map.merge(%{ + user_id: user.id, + token_hash: token_hash, + last_seen_at: current_time, + expires_at: DateTime.add(current_time, @session_lifetime_days, :day) + }) + + case %__MODULE__{} |> changeset(attrs) |> repo.insert() do + {:ok, session} -> {:ok, session, token} + {:error, changeset} -> {:error, changeset} + end + end + + defp renew_or_create(repo, user, current_session, attrs) do + {token, token_hash} = generate_token() + current_time = now() + + changes = + attrs + |> Map.new() + |> Map.merge(%{ + token_hash: token_hash, + last_seen_at: current_time, + expires_at: DateTime.add(current_time, @session_lifetime_days, :day) + }) + + current_session + |> changeset(changes) + |> repo.update() + |> case do + {:ok, session} when session.user_id == user.id -> {:ok, session, token} + {:ok, _session} -> {:error, :invalid_session_owner} + {:error, changeset} -> {:error, changeset} + end + end + + defp touch(nil, _current_time), do: nil + + defp touch(session, current_time) do + if DateTime.diff(current_time, session.last_seen_at, :second) >= @touch_interval_seconds do + session + |> change(last_seen_at: current_time) + |> Repo.update() + |> case do + {:ok, updated_session} -> updated_session + {:error, _changeset} -> session + end + else + session + end + end + + defp generate_token do + token = @token_bytes |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false) + {token, hash_token(token)} + end + + defp hash_token(token), do: :crypto.hash(:sha256, token) + defp now, do: DateTime.truncate(DateTime.utc_now(), :second) +end diff --git a/apps/codebattle/lib/codebattle_web/channels/invite_channel.ex b/apps/codebattle/lib/codebattle_web/channels/invite_channel.ex index 5086f8912..a733fd57b 100644 --- a/apps/codebattle/lib/codebattle_web/channels/invite_channel.ex +++ b/apps/codebattle/lib/codebattle_web/channels/invite_channel.ex @@ -97,6 +97,23 @@ defmodule CodebattleWeb.InviteChannel do {:error, reason} -> {:reply, {:error, %{reason: reason}}, socket} + + {:error, reason, invite} -> + data = invite_payload(invite, user_id) + + broadcast_invite( + "invites:#{invite.creator_id}", + "invites:dropped", + %{invite: data} + ) + + broadcast_invite( + "invites:#{invite.recipient_id}", + "invites:dropped", + %{invite: data} + ) + + {:reply, {:error, %{reason: reason, invite: data}}, socket} end end @@ -195,6 +212,20 @@ defmodule CodebattleWeb.InviteChannel do end end + defp invite_payload(invite, executor_id) do + %{ + state: invite.state, + id: invite.id, + game_id: invite.game_id, + game_params: invite.game_params, + creator_id: invite.creator_id, + recipient_id: invite.recipient_id, + executor_id: executor_id, + creator: invite.creator, + recipient: invite.recipient + } + end + defp broadcast_invite(topic, event, payload) do message = %Message{ topic: topic, diff --git a/apps/codebattle/lib/codebattle_web/channels/user_socket.ex b/apps/codebattle/lib/codebattle_web/channels/user_socket.ex index 07da9fdd4..52ebcc447 100644 --- a/apps/codebattle/lib/codebattle_web/channels/user_socket.ex +++ b/apps/codebattle/lib/codebattle_web/channels/user_socket.ex @@ -2,6 +2,7 @@ defmodule CodebattleWeb.UserSocket do use Phoenix.Socket alias Codebattle.User + alias Codebattle.UserSession ## Channels channel("lobby", CodebattleWeb.LobbyChannel) @@ -23,16 +24,35 @@ defmodule CodebattleWeb.UserSocket do {:ok, ^guest_id} -> {:ok, assign(socket, current_user: User.build_guest(), access_token: access_token)} - {:ok, user_id} -> - {:ok, assign(socket, current_user: User.get!(user_id), access_token: access_token)} + {:ok, {user_id, session_id}} -> + connect_user(socket, user_id, session_id, access_token) + + {:ok, _user_id} -> + :error {:error, _reason} -> :error end end + def id(%{assigns: %{current_user_session: %{id: session_id}}}), do: "user_session:#{session_id}" def id(_socket), do: nil + defp connect_user(socket, user_id, session_id, access_token) do + case UserSession.get_active_for_socket(user_id, session_id) do + %UserSession{user: user} = session -> + {:ok, + assign(socket, + current_user: user, + current_user_session: session, + access_token: access_token + )} + + _ -> + :error + end + end + defp normalize_access_token(nil), do: nil defp normalize_access_token(access_token) when is_binary(access_token) do diff --git a/apps/codebattle/lib/codebattle_web/controllers/api/v1/session_controller.ex b/apps/codebattle/lib/codebattle_web/controllers/api/v1/session_controller.ex index 947170e91..79e007e13 100644 --- a/apps/codebattle/lib/codebattle_web/controllers/api/v1/session_controller.ex +++ b/apps/codebattle/lib/codebattle_web/controllers/api/v1/session_controller.ex @@ -1,6 +1,8 @@ defmodule CodebattleWeb.Api.V1.SessionController do use CodebattleWeb, :controller + alias CodebattleWeb.UserAuth + def create(conn, params) do user_attrs = %{ email: params["email"], @@ -10,8 +12,7 @@ defmodule CodebattleWeb.Api.V1.SessionController do case Codebattle.Auth.User.find_by_firebase(user_attrs) do {:ok, user} -> conn - |> configure_session(renew: true) - |> put_session(:user_id, user.id) + |> UserAuth.log_in_user(user) |> json(%{status: :created}) {:error, reason} -> diff --git a/apps/codebattle/lib/codebattle_web/controllers/api/v1/settings_controller.ex b/apps/codebattle/lib/codebattle_web/controllers/api/v1/settings_controller.ex index 806d7e703..0eac93e8b 100644 --- a/apps/codebattle/lib/codebattle_web/controllers/api/v1/settings_controller.ex +++ b/apps/codebattle/lib/codebattle_web/controllers/api/v1/settings_controller.ex @@ -3,6 +3,13 @@ defmodule CodebattleWeb.Api.V1.SettingsController do alias Codebattle.Repo alias Codebattle.User + alias Codebattle.UserSession + alias CodebattleWeb.UserAuth + + plug(CodebattleWeb.Plugs.ApiRequireAuth) + + @password_attempt_limit 5 + @password_attempt_ttl to_timeout(minute: 5) def show(conn, _params) do current_user = conn.assigns.current_user @@ -11,6 +18,7 @@ defmodule CodebattleWeb.Api.V1.SettingsController do name: current_user.name, clan: current_user.clan, locale: current_user.locale, + has_password: User.has_password?(current_user), can_unlink_social: User.can_unlink_social?(current_user), discord_id: current_user.discord_id, discord_name: current_user.discord_name, @@ -35,6 +43,7 @@ defmodule CodebattleWeb.Api.V1.SettingsController do name: user.name, clan: user.clan, locale: user.locale, + has_password: User.has_password?(user), can_unlink_social: User.can_unlink_social?(user), sound_settings: user.sound_settings, lang: user.lang, @@ -48,4 +57,108 @@ defmodule CodebattleWeb.Api.V1.SettingsController do |> json(%{errors: translate_errors(changeset)}) end end + + def update_password(conn, params) do + current_user = conn.assigns.current_user + current_session = conn.assigns.current_user_session + + if password_rate_limited?(current_user.id) do + conn + |> put_status(:too_many_requests) + |> json(%{errors: %{current_password: ["too many attempts, try again later"]}}) + else + case User.update_password( + current_user, + current_session, + params, + UserAuth.session_metadata(conn) + ) do + {:ok, _user, session_data} -> + Cachex.del(:password_attempts_cache, current_user.id) + + conn + |> UserAuth.put_session_token(session_data.token) + |> json(%{status: "ok", has_password: true}) + + {:error, %Ecto.Changeset{} = changeset} -> + register_password_failure(current_user.id) + + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: translate_errors(changeset)}) + + {:error, _reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: %{current_password: ["session could not be renewed"]}}) + end + end + end + + def sessions(conn, _params) do + current_session_id = conn.assigns.current_user_session.id + + sessions = + conn.assigns.current_user.id + |> UserSession.list_active() + |> Enum.map(&present_session(&1, current_session_id)) + + json(conn, %{sessions: sessions}) + end + + def delete_user_session(conn, %{"id" => session_id}) do + current_session = conn.assigns.current_user_session + + case UserSession.revoke_for_user(conn.assigns.current_user.id, session_id) do + {:ok, revoked_session} -> + current = revoked_session.id == current_session.id + + conn = + if current do + conn + |> clear_session() + |> configure_session(drop: true) + else + conn + end + + json(conn, %{status: "ok", current: current}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "session not found"}) + + {:error, _changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "session could not be revoked"}) + end + end + + defp password_rate_limited?(user_id) do + case Cachex.get(:password_attempts_cache, user_id) do + {:ok, attempts} when is_integer(attempts) -> attempts >= @password_attempt_limit + _ -> false + end + end + + defp register_password_failure(user_id) do + {:ok, attempts} = Cachex.incr(:password_attempts_cache, user_id) + + if attempts == 1 do + Cachex.expire(:password_attempts_cache, user_id, @password_attempt_ttl) + end + end + + defp present_session(session, current_session_id) do + %{ + id: session.id, + current: session.id == current_session_id, + user_agent: session.user_agent, + ip: session.ip, + last_seen_at: session.last_seen_at, + created_at: session.inserted_at + } + end end diff --git a/apps/codebattle/lib/codebattle_web/controllers/api/v1/user_controller.ex b/apps/codebattle/lib/codebattle_web/controllers/api/v1/user_controller.ex index 540c5dcd1..765115d79 100644 --- a/apps/codebattle/lib/codebattle_web/controllers/api/v1/user_controller.ex +++ b/apps/codebattle/lib/codebattle_web/controllers/api/v1/user_controller.ex @@ -11,6 +11,7 @@ defmodule CodebattleWeb.Api.V1.UserController do alias Codebattle.User.Achievements alias Codebattle.User.Stats, as: UserStats alias CodebattleWeb.Api.UserView + alias CodebattleWeb.UserAuth def index(conn, params) do payload = UserView.render_rating(params) @@ -34,8 +35,7 @@ defmodule CodebattleWeb.Api.V1.UserController do case Codebattle.Auth.User.create_in_firebase(user_attrs) do {:ok, user} -> conn - |> configure_session(renew: true) - |> put_session(:user_id, user.id) + |> UserAuth.log_in_user(user) |> json(%{status: :created}) {:error, errors} -> diff --git a/apps/codebattle/lib/codebattle_web/controllers/auth_controller.ex b/apps/codebattle/lib/codebattle_web/controllers/auth_controller.ex index 22c6e27af..c5531fd63 100644 --- a/apps/codebattle/lib/codebattle_web/controllers/auth_controller.ex +++ b/apps/codebattle/lib/codebattle_web/controllers/auth_controller.ex @@ -5,6 +5,7 @@ defmodule CodebattleWeb.AuthController do alias Codebattle.Auth.Discord alias Codebattle.Auth.External alias Codebattle.Auth.Github + alias CodebattleWeb.UserAuth require Logger @@ -14,9 +15,8 @@ defmodule CodebattleWeb.AuthController do case Codebattle.Auth.User.find_by_token(token) do {:ok, user} -> conn - |> configure_session(renew: true) + |> UserAuth.log_in_user(user) |> put_flash(:info, gettext("Successfully authenticated")) - |> put_session(:user_id, user.id) |> redirect(to: "/") {:error, reason} -> @@ -85,9 +85,8 @@ defmodule CodebattleWeb.AuthController do case case_result do {:ok, user} -> conn - |> configure_session(renew: true) + |> UserAuth.log_in_user(user) |> put_flash(:info, gettext("Successfully authenticated")) - |> put_session(:user_id, user.id) |> redirect(to: next_path) {:error, reason} -> diff --git a/apps/codebattle/lib/codebattle_web/controllers/dev_login_controller.ex b/apps/codebattle/lib/codebattle_web/controllers/dev_login_controller.ex index 2543c37b7..c5e79b619 100644 --- a/apps/codebattle/lib/codebattle_web/controllers/dev_login_controller.ex +++ b/apps/codebattle/lib/codebattle_web/controllers/dev_login_controller.ex @@ -1,6 +1,8 @@ defmodule CodebattleWeb.DevLoginController do use CodebattleWeb, :controller + alias CodebattleWeb.UserAuth + def create(conn, params) do if Application.get_env(:codebattle, :dev_sign_in) do subscription_type = Map.get(params, "subscription_type", "free") @@ -18,9 +20,8 @@ defmodule CodebattleWeb.DevLoginController do case Codebattle.Auth.User.create_dev_user(params) do {:ok, user} -> conn - |> configure_session(renew: true) + |> UserAuth.log_in_user(user) |> put_flash(:success, gettext("Successfully authenticated.")) - |> put_session(:user_id, user.id) |> redirect(to: "/") {:error, reason} -> diff --git a/apps/codebattle/lib/codebattle_web/controllers/ext_api/load_test_controller.ex b/apps/codebattle/lib/codebattle_web/controllers/ext_api/load_test_controller.ex index c61d71c93..0beea16a4 100644 --- a/apps/codebattle/lib/codebattle_web/controllers/ext_api/load_test_controller.ex +++ b/apps/codebattle/lib/codebattle_web/controllers/ext_api/load_test_controller.ex @@ -392,7 +392,9 @@ defmodule CodebattleWeb.ExtApi.LoadTestController do end defp sign_user_token(user_id) do - Phoenix.Token.sign(%Phoenix.Socket{endpoint: CodebattleWeb.Endpoint}, "user_token", user_id) + user = User.get!(user_id) + {:ok, token} = Codebattle.UserSession.create_socket_token(user, %{user_agent: "Codebattle load test"}) + token end defp scenario_tournament_params(params) do diff --git a/apps/codebattle/lib/codebattle_web/controllers/game_controller.ex b/apps/codebattle/lib/codebattle_web/controllers/game_controller.ex index e59159239..26d62ad06 100644 --- a/apps/codebattle/lib/codebattle_web/controllers/game_controller.ex +++ b/apps/codebattle/lib/codebattle_web/controllers/game_controller.ex @@ -195,8 +195,8 @@ defmodule CodebattleWeb.GameController do game_params = %{ level: task.level, task: task, - mode: "training", - use_chat: false, + mode: "standard", + use_chat: true, visibility_type: "hidden", players: [conn.assigns.current_user, Codebattle.Bot.Context.build()] } diff --git a/apps/codebattle/lib/codebattle_web/controllers/session_controller.ex b/apps/codebattle/lib/codebattle_web/controllers/session_controller.ex index bf1baf2a1..58f0b01b0 100644 --- a/apps/codebattle/lib/codebattle_web/controllers/session_controller.ex +++ b/apps/codebattle/lib/codebattle_web/controllers/session_controller.ex @@ -2,6 +2,7 @@ defmodule CodebattleWeb.SessionController do use CodebattleWeb, :controller alias Codebattle.User + alias CodebattleWeb.UserAuth plug(:put_view, CodebattleWeb.SessionView) plug(:put_layout, html: {CodebattleWeb.LayoutView, :app}) @@ -48,9 +49,8 @@ defmodule CodebattleWeb.SessionController do user -> conn - |> configure_session(renew: true) + |> UserAuth.log_in_user(user) |> put_flash(:info, gettext("Welcome to Codebattle!")) - |> put_session(:user_id, user.id) |> redirect(to: "/") end end @@ -60,7 +60,7 @@ defmodule CodebattleWeb.SessionController do def delete(conn, _params) do conn |> put_flash(:info, gettext("You have been logged out!")) - |> configure_session(drop: true) + |> UserAuth.log_out_user() |> redirect(to: "/") end diff --git a/apps/codebattle/lib/codebattle_web/live/admin/user_show_view.ex b/apps/codebattle/lib/codebattle_web/live/admin/user_show_view.ex index ba463c2f9..eeec81c55 100644 --- a/apps/codebattle/lib/codebattle_web/live/admin/user_show_view.ex +++ b/apps/codebattle/lib/codebattle_web/live/admin/user_show_view.ex @@ -245,7 +245,8 @@ defmodule CodebattleWeb.Live.Admin.UserShowView do {:ok, token_record} = UserGroupTournamentContext.get_or_create_token(ugt.group_tournament, user.id) token_value = token_record.token - result = ExternalPlatform.upsert_secret(org_slug, repo_slug, secret_key, token_value, secret_group: secret_group) + result = + ExternalPlatform.upsert_secret(org_slug, repo_slug, secret_key, token_value, secret_group: secret_group) ugt = update_ugt_from_result(ugt, :secret, result) diff --git a/apps/codebattle/lib/codebattle_web/plugs/assign_current_user.ex b/apps/codebattle/lib/codebattle_web/plugs/assign_current_user.ex index 886e42d1d..d61abf3a5 100644 --- a/apps/codebattle/lib/codebattle_web/plugs/assign_current_user.ex +++ b/apps/codebattle/lib/codebattle_web/plugs/assign_current_user.ex @@ -4,7 +4,9 @@ defmodule CodebattleWeb.Plugs.AssignCurrentUser do import Plug.Conn alias Codebattle.User + alias Codebattle.UserSession alias CodebattleWeb.Router.Helpers, as: Routes + alias CodebattleWeb.UserAuth require Logger @@ -13,43 +15,64 @@ defmodule CodebattleWeb.Plugs.AssignCurrentUser do @spec call(Plug.Conn.t(), Keyword.t()) :: Plug.Conn.t() def call(conn, _opts) do - user_id = get_session(conn, :user_id) + case current_session(conn) do + {:ok, %UserSession{user: user} = session} -> + assign_user(conn, user, session) - case user_id do - nil -> - assign(conn, :current_user, User.build_guest()) + :guest -> + assign_guest(conn) - id -> - case User.get(id) do - nil -> - handle_missing_user(conn) - - %User{subscription_type: :banned} -> - html = Phoenix.View.render_to_string(CodebattleWeb.LayoutView, "banned.html", conn: conn) - - conn - |> put_resp_content_type("text/html") - |> send_resp(403, html) - |> halt() + :stale -> + handle_stale_session(conn) + end + end - user -> - assign(conn, :current_user, user) + defp current_session(conn) do + case UserAuth.session_token(conn) do + token when is_binary(token) -> + case UserSession.get_active_by_token(token) do + nil -> :stale + session -> {:ok, session} end + + _ -> + if get_session(conn, :user_id), do: :stale, else: :guest end end - defp handle_missing_user(conn) do - Logger.warning("Clearing invalid session: user_id=#{inspect(get_session(conn, :user_id))} path=#{conn.request_path}") + defp assign_user(conn, %User{subscription_type: :banned}, _session) do + html = Phoenix.View.render_to_string(CodebattleWeb.LayoutView, "banned.html", conn: conn) + + conn + |> put_resp_content_type("text/html") + |> send_resp(403, html) + |> halt() + end + + defp assign_user(conn, user, session) do + conn + |> assign(:current_user, user) + |> assign(:current_user_session, session) + end + + defp assign_guest(conn) do + conn + |> assign(:current_user, User.build_guest()) + |> assign(:current_user_session, nil) + end + + defp handle_stale_session(conn) do + Logger.info("Clearing invalid or revoked user session for path=#{conn.request_path}") conn = clear_session(conn) if get_format(conn) == "html" do conn - |> put_flash(:danger, "You must be logged in to access that page") + |> put_flash(:danger, "Your password changed. Please sign in again") |> redirect(to: Routes.session_path(conn, :new)) |> halt() else - assign(conn, :current_user, User.build_guest()) + assign_guest(conn) end end end diff --git a/apps/codebattle/lib/codebattle_web/plugs/assign_inertia_props.ex b/apps/codebattle/lib/codebattle_web/plugs/assign_inertia_props.ex index 7187b640f..0e3d6e556 100644 --- a/apps/codebattle/lib/codebattle_web/plugs/assign_inertia_props.ex +++ b/apps/codebattle/lib/codebattle_web/plugs/assign_inertia_props.ex @@ -3,6 +3,8 @@ defmodule CodebattleWeb.Plugs.AssignInertiaProps do import Inertia.Controller + alias Codebattle.UserSession + @spec init(Keyword.t()) :: Keyword.t() def init(opts), do: opts @@ -14,8 +16,19 @@ defmodule CodebattleWeb.Plugs.AssignInertiaProps do props = %{ "current_season" => present_season(current_season), "current_user" => present_user(current_user), + "user_sessions" => + present_user_sessions( + current_user, + conn.assigns.current_user_session, + conn.request_path + ), "locale" => Gettext.get_locale(CodebattleWeb.Gettext), - "user_token" => Phoenix.Token.sign(conn, "user_token", current_user.id) + "user_token" => + Phoenix.Token.sign( + conn, + "user_token", + socket_token_payload(current_user, conn.assigns.current_user_session) + ) } conn @@ -30,6 +43,27 @@ defmodule CodebattleWeb.Plugs.AssignInertiaProps do Map.take(season, [:name, :year, :starts_at, :ends_at]) end + defp socket_token_payload(%{is_guest: true} = user, nil), do: user.id + defp socket_token_payload(user, session), do: {user.id, session.id} + + defp present_user_sessions(%{is_guest: true}, _current_session, _request_path), do: [] + defp present_user_sessions(_user, _current_session, request_path) when request_path != "/settings", do: [] + + defp present_user_sessions(user, current_session, "/settings") do + user.id + |> UserSession.list_active() + |> Enum.map(fn session -> + %{ + id: session.id, + current: session.id == current_session.id, + user_agent: session.user_agent, + ip: session.ip, + last_seen_at: session.last_seen_at, + created_at: session.inserted_at + } + end) + end + defp present_user(user) do user |> Map.take([ @@ -66,6 +100,7 @@ defmodule CodebattleWeb.Plugs.AssignInertiaProps do :sound_settings ]) |> Map.put(:can_unlink_social, Codebattle.User.can_unlink_social?(user)) + |> Map.put(:has_password, Codebattle.User.has_password?(user)) |> Map.put(:is_admin, Codebattle.User.admin?(user)) end end diff --git a/apps/codebattle/lib/codebattle_web/router.ex b/apps/codebattle/lib/codebattle_web/router.ex index 32c78af73..a11b6db42 100644 --- a/apps/codebattle/lib/codebattle_web/router.ex +++ b/apps/codebattle/lib/codebattle_web/router.ex @@ -314,6 +314,9 @@ defmodule CodebattleWeb.Router do resources("/reset_password", ResetPasswordController, only: [:create], singleton: true) resources("/session", SessionController, only: [:create], singleton: true) resources("/settings", SettingsController, only: [:show, :update], singleton: true) + patch("/settings/password", SettingsController, :update_password) + get("/settings/sessions", SettingsController, :sessions) + delete("/settings/sessions/:id", SettingsController, :delete_user_session) resources("/tasks", TaskController, only: [:index, :show, :update]) get("/tasks/:id/stats", TaskController, :stats) get("/tournaments/history", TournamentController, :history) diff --git a/apps/codebattle/lib/codebattle_web/user_auth.ex b/apps/codebattle/lib/codebattle_web/user_auth.ex new file mode 100644 index 000000000..91acaa2f2 --- /dev/null +++ b/apps/codebattle/lib/codebattle_web/user_auth.ex @@ -0,0 +1,63 @@ +defmodule CodebattleWeb.UserAuth do + @moduledoc false + + import Plug.Conn + + alias Codebattle.UserSession + + @session_token_key :user_session_token + + def log_in_user(conn, user) do + {:ok, _session, token} = UserSession.create(user, session_metadata(conn)) + + conn + |> configure_session(renew: true) + |> delete_session(:user_id) + |> delete_session(:session_version) + |> put_session(@session_token_key, token) + end + + def log_out_user(conn) do + conn + |> get_session(@session_token_key) + |> UserSession.revoke_by_token() + + conn + |> clear_session() + |> configure_session(drop: true) + end + + def put_session_token(conn, token) do + conn + |> configure_session(renew: true) + |> delete_session(:user_id) + |> delete_session(:session_version) + |> put_session(@session_token_key, token) + end + + def session_token(conn), do: get_session(conn, @session_token_key) + + def session_metadata(conn) do + [ + user_agent: + conn + |> get_req_header("user-agent") + |> List.first() + |> truncate(512), + ip: format_ip(conn.remote_ip) + ] + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + end + + defp format_ip(ip) do + ip + |> :inet.ntoa() + |> to_string() + rescue + ArgumentError -> nil + end + + defp truncate(nil, _max_length), do: nil + defp truncate(value, max_length), do: String.slice(value, 0, max_length) +end diff --git a/apps/codebattle/lib/codebattle_web/views/admin/dev_solution_tester_view.ex b/apps/codebattle/lib/codebattle_web/views/admin/dev_solution_tester_view.ex deleted file mode 100644 index 8b1378917..000000000 --- a/apps/codebattle/lib/codebattle_web/views/admin/dev_solution_tester_view.ex +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/codebattle/lib/codebattle_web/views/api/task_view.ex b/apps/codebattle/lib/codebattle_web/views/api/task_view.ex index 0413ad495..4982ee236 100644 --- a/apps/codebattle/lib/codebattle_web/views/api/task_view.ex +++ b/apps/codebattle/lib/codebattle_web/views/api/task_view.ex @@ -5,6 +5,8 @@ defmodule CodebattleWeb.Api.TaskView do %{ id: task.id, name: task.name, + description_en: task.description_en, + description_ru: task.description_ru, level: task.level, origin: task.origin, creator_id: task.creator_id, diff --git a/apps/codebattle/lib/codebattle_web/views/group_task_view.ex b/apps/codebattle/lib/codebattle_web/views/group_task_view.ex deleted file mode 100644 index 8b1378917..000000000 --- a/apps/codebattle/lib/codebattle_web/views/group_task_view.ex +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/codebattle/lib/codebattle_web/views/raw_task_view.ex b/apps/codebattle/lib/codebattle_web/views/raw_task_view.ex deleted file mode 100644 index 8b1378917..000000000 --- a/apps/codebattle/lib/codebattle_web/views/raw_task_view.ex +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/codebattle/priv/gettext/en/LC_MESSAGES/default.po b/apps/codebattle/priv/gettext/en/LC_MESSAGES/default.po index b0d66beff..f2b500f99 100644 --- a/apps/codebattle/priv/gettext/en/LC_MESSAGES/default.po +++ b/apps/codebattle/priv/gettext/en/LC_MESSAGES/default.po @@ -1195,8 +1195,8 @@ msgstr "Format" msgid "Kick player" msgstr "Kick player" -msgid "Kick {{name}} from tournament?" -msgstr "Kick {{name}} from tournament?" +msgid "Kick %{name} from tournament?" +msgstr "Kick %{name} from tournament?" msgid "Last page" msgstr "Last page" @@ -1288,3 +1288,98 @@ msgstr "Chat message" msgid "Wins" msgstr "Wins" +msgid "Your weapon" +msgstr "Your weapon" + +msgid "No access yet" +msgstr "No access yet" + +msgid "Your name" +msgstr "Your name" + +msgid "Enter your name" +msgstr "Enter your name" + +msgid "Your clan" +msgstr "Your clan" + +msgid "clan list" +msgstr "clan list" + +msgid "Enter your clan" +msgstr "Enter your clan" + +msgid "Locale" +msgstr "Locale" + +msgid "Select sound type" +msgstr "Select sound type" + +msgid "Dendy" +msgstr "Dendy" + +msgid "CS" +msgstr "CS" + +msgid "Standard" +msgstr "Standard" + +msgid "Silent" +msgstr "Silent" + +msgid "Select sound level" +msgstr "Select sound level" + +msgid "Select tournament sound level" +msgstr "Select tournament sound level" + +msgid "Save" +msgstr "Save" + +msgid "Socials" +msgstr "Socials" + +msgid "Unlink %{provider}" +msgstr "Unlink %{provider}" + +msgid "Link %{provider}" +msgstr "Link %{provider}" + +msgid "Change password" +msgstr "Change password" + +msgid "Old password" +msgstr "Old password" + +msgid "Enter old password" +msgstr "Enter old password" + +msgid "New password" +msgstr "New password" + +msgid "Enter new password" +msgstr "Enter new password" + +msgid "Confirm new password" +msgstr "Confirm new password" + +msgid "Active devices" +msgstr "Active devices" + +msgid "No active devices" +msgstr "No active devices" + +msgid "Unknown device" +msgstr "Unknown device" + +msgid "Current device" +msgstr "Current device" + +msgid "Remove device %{device}" +msgstr "Remove device %{device}" + +msgid "Sign out" +msgstr "Sign out" + +msgid "Remove" +msgstr "Remove" diff --git a/apps/codebattle/priv/gettext/ru/LC_MESSAGES/default.po b/apps/codebattle/priv/gettext/ru/LC_MESSAGES/default.po index 871d4432a..9c1988258 100644 --- a/apps/codebattle/priv/gettext/ru/LC_MESSAGES/default.po +++ b/apps/codebattle/priv/gettext/ru/LC_MESSAGES/default.po @@ -1883,8 +1883,8 @@ msgstr "Формат" msgid "Kick player" msgstr "Исключить игрока" -msgid "Kick {{name}} from tournament?" -msgstr "Исключить {{name}} из турнира?" +msgid "Kick %{name} from tournament?" +msgstr "Исключить %{name} из турнира?" msgid "Last page" msgstr "Последняя страница" @@ -1970,3 +1970,95 @@ msgstr "Напишите сообщение..." msgid "Chat message" msgstr "Сообщение в чат" +msgid "Your weapon" +msgstr "Ваш язык" + +msgid "No access yet" +msgstr "Пока недоступно" + +msgid "Your name" +msgstr "Ваше имя" + +msgid "Your clan" +msgstr "Ваш клан" + +msgid "clan list" +msgstr "список кланов" + +msgid "Enter your clan" +msgstr "Введите название клана" + +msgid "Locale" +msgstr "Язык интерфейса" + +msgid "Select sound type" +msgstr "Выберите набор звуков" + +msgid "Dendy" +msgstr "Dendy" + +msgid "CS" +msgstr "CS" + +msgid "Standard" +msgstr "Стандартный" + +msgid "Silent" +msgstr "Без звука" + +msgid "Select sound level" +msgstr "Громкость игры" + +msgid "Select tournament sound level" +msgstr "Громкость турнира" + +msgid "Save" +msgstr "Сохранить" + +msgid "Socials" +msgstr "Социальные сети" + +msgid "Unlink %{provider}" +msgstr "Отвязать %{provider}" + +msgid "Link %{provider}" +msgstr "Привязать %{provider}" + +msgid "Change password" +msgstr "Изменить пароль" + +msgid "Old password" +msgstr "Текущий пароль" + +msgid "Enter old password" +msgstr "Введите текущий пароль" + +msgid "New password" +msgstr "Новый пароль" + +msgid "Enter new password" +msgstr "Введите новый пароль" + +msgid "Confirm new password" +msgstr "Подтвердите новый пароль" + +msgid "Active devices" +msgstr "Активные устройства" + +msgid "No active devices" +msgstr "Нет активных устройств" + +msgid "Unknown device" +msgstr "Неизвестное устройство" + +msgid "Current device" +msgstr "Текущее устройство" + +msgid "Remove device %{device}" +msgstr "Удалить устройство %{device}" + +msgid "Sign out" +msgstr "Выйти" + +msgid "Remove" +msgstr "Удалить" diff --git a/apps/codebattle/priv/repo/migrations/20260723190000_create_user_sessions.exs b/apps/codebattle/priv/repo/migrations/20260723190000_create_user_sessions.exs new file mode 100644 index 000000000..8e9bec9c1 --- /dev/null +++ b/apps/codebattle/priv/repo/migrations/20260723190000_create_user_sessions.exs @@ -0,0 +1,23 @@ +defmodule Codebattle.Repo.Migrations.CreateUserSessions do + @moduledoc false + use Ecto.Migration + + def change do + create table(:user_sessions, primary_key: false) do + add(:id, :binary_id, primary_key: true) + add(:user_id, references(:users, on_delete: :delete_all), null: false) + add(:token_hash, :binary, null: false) + add(:user_agent, :string, size: 512) + add(:ip, :string, size: 64) + add(:last_seen_at, :utc_datetime, null: false) + add(:expires_at, :utc_datetime, null: false) + add(:revoked_at, :utc_datetime) + + timestamps() + end + + create(unique_index(:user_sessions, [:token_hash])) + create(index(:user_sessions, [:user_id, :revoked_at])) + create(index(:user_sessions, [:expires_at])) + end +end diff --git a/apps/codebattle/test/codebattle/bot/playbook_player_test.exs b/apps/codebattle/test/codebattle/bot/playbook_player_test.exs index c9dc726e9..ecf07ed04 100644 --- a/apps/codebattle/test/codebattle/bot/playbook_player_test.exs +++ b/apps/codebattle/test/codebattle/bot/playbook_player_test.exs @@ -113,7 +113,7 @@ defmodule Codebattle.Bot.PlaybookPlayerTest do task = insert(:task, level: "easy", time_to_solve_sec: 30, solutions: %{"python" => @python_solution}) user = insert(:user, %{name: "first", email: "test1@test.test", github_id: 1, rating: 1400}) - conn = put_session(conn, :user_id, user.id) + conn = log_in_user(conn, user.id) socket = socket(UserSocket, "user_id", %{user_id: user.id, current_user: user}) bot = Bot.Context.build() diff --git a/apps/codebattle/test/codebattle/bot/playbook_store_test.exs b/apps/codebattle/test/codebattle/bot/playbook_store_test.exs index 39e5df25c..192ee96e3 100644 --- a/apps/codebattle/test/codebattle/bot/playbook_store_test.exs +++ b/apps/codebattle/test/codebattle/bot/playbook_store_test.exs @@ -11,7 +11,7 @@ defmodule Codebattle.PlaybookStoreTest do user1 = insert(:user, %{name: "first", email: "test1@test.test", github_id: 1, rating: 1000}) user2 = insert(:user, %{name: "second", email: "test2@test.test", github_id: 2, rating: 1000}) - conn2 = put_session(conn, :user_id, user2.id) + conn2 = log_in_user(conn, user2.id) socket1 = socket(UserSocket, "user_id", %{user_id: user1.id, current_user: user1}) socket2 = socket(UserSocket, "user_id", %{user_id: user2.id, current_user: user2}) diff --git a/apps/codebattle/test/codebattle/game/elo_test.exs b/apps/codebattle/test/codebattle/game/elo_test.exs index feef3389d..83d399b20 100644 --- a/apps/codebattle/test/codebattle/game/elo_test.exs +++ b/apps/codebattle/test/codebattle/game/elo_test.exs @@ -19,6 +19,8 @@ defmodule Codebattle.Game.EloTest do assert {1200, 1200} = Elo.calc_elo(1200, 1200, "grand_slam", :draw) assert_raise ArgumentError, ~r/result must be :win or :draw/, fn -> + # Deliberately bypass the public type contract to verify the runtime guard. + # credo:disable-for-next-line Credo.Check.Refactor.Apply apply(Elo, :calc_elo, [1200, 1200, "grand_slam", :loss]) end end diff --git a/apps/codebattle/test/codebattle/invite_test.exs b/apps/codebattle/test/codebattle/invite_test.exs index 54b88abf3..ee3677e0c 100644 --- a/apps/codebattle/test/codebattle/invite_test.exs +++ b/apps/codebattle/test/codebattle/invite_test.exs @@ -1,6 +1,7 @@ defmodule Codebattle.InviteTest do use Codebattle.DataCase, async: false + alias Codebattle.Game.Context alias Codebattle.Invite test "manages invite lifecycle and accepts a task-specific challenge" do @@ -71,4 +72,32 @@ defmodule Codebattle.InviteTest do refute changeset.valid? end + + test "drops an invite when one of its players has already started another game" do + creator = insert(:user, subscription_type: :premium) + recipient = insert(:user, subscription_type: :premium) + + assert {:ok, active_game} = + Context.create_game(%{ + players: [creator, Codebattle.Bot.Context.build()], + state: "playing" + }) + + on_exit(fn -> Context.terminate_game(active_game.id) end) + + invite = + insert(:invite, + creator: creator, + recipient: recipient, + state: "pending", + game_params: %{} + ) + + assert {:error, :already_in_a_game, dropped_invite} = + Invite.accept_invite(%{id: invite.id, recipient_id: recipient.id}) + + assert dropped_invite.state == "dropped" + assert Invite.get_invite!(invite.id).state == "dropped" + assert Invite.list_active_invites(recipient.id) == [] + end end diff --git a/apps/codebattle/test/codebattle/tournament/context_test.exs b/apps/codebattle/test/codebattle/tournament/context_test.exs index 4ad8d1e1b..33f64f802 100644 --- a/apps/codebattle/test/codebattle/tournament/context_test.exs +++ b/apps/codebattle/test/codebattle/tournament/context_test.exs @@ -64,6 +64,27 @@ defmodule Codebattle.Tournament.ContextTest do assert Tournament.Context.get_user_tournaments(%{user: %{is_guest: true}}) == [] end + test "includes tournaments where the user is a registered player" do + creator = insert(:user) + player = insert(:user) + now = DateTime.utc_now(:second) + + tournament = + insert(:tournament, + creator_id: creator.id, + starts_at: now, + players: %{player.id => %{id: player.id, is_bot: false}} + ) + + filter = %{ + from: DateTime.add(now, -1, :day), + to: DateTime.add(now, 1, :day), + user: player + } + + assert Enum.map(Tournament.Context.get_user_tournaments(filter), & &1.id) == [tournament.id] + end + test "validates tournament parameters without persisting" do changeset = Tournament.Context.validate(%{"name" => "", "rounds_limit" => 0}) refute changeset.valid? diff --git a/apps/codebattle/test/codebattle_web/channels/chat_channel_test.exs b/apps/codebattle/test/codebattle_web/channels/chat_channel_test.exs index 6d55e3ead..e165e3180 100644 --- a/apps/codebattle/test/codebattle_web/channels/chat_channel_test.exs +++ b/apps/codebattle/test/codebattle_web/channels/chat_channel_test.exs @@ -11,13 +11,13 @@ defmodule CodebattleWeb.ChatChannelTest do user2 = insert(:user, name: "bob") admin = insert(:admin) - user_token1 = Phoenix.Token.sign(socket(UserSocket), "user_token", user1.id) + user_token1 = user_socket_token(user1) {:ok, socket1} = connect(UserSocket, %{"token" => user_token1}) - user_token2 = Phoenix.Token.sign(socket(UserSocket), "user_token", user2.id) + user_token2 = user_socket_token(user2) {:ok, socket2} = connect(UserSocket, %{"token" => user_token2}) - admin_token = Phoenix.Token.sign(socket(UserSocket), "user_token", admin.id) + admin_token = user_socket_token(admin) {:ok, admin_socket} = connect(UserSocket, %{"token" => admin_token}) {:ok, %{user1: user1, user2: user2, socket1: socket1, socket2: socket2, admin_socket: admin_socket}} diff --git a/apps/codebattle/test/codebattle_web/channels/game_channel_test.exs b/apps/codebattle/test/codebattle_web/channels/game_channel_test.exs index 964189d2b..f1d70c034 100644 --- a/apps/codebattle/test/codebattle_web/channels/game_channel_test.exs +++ b/apps/codebattle/test/codebattle_web/channels/game_channel_test.exs @@ -17,13 +17,13 @@ defmodule CodebattleWeb.GameChannelTest do spectator = insert(:admin) insert(:task, level: "easy") - user_token1 = Phoenix.Token.sign(socket(UserSocket), "user_token", user1.id) + user_token1 = user_socket_token(user1) {:ok, socket1} = connect(UserSocket, %{"token" => user_token1}) - user_token2 = Phoenix.Token.sign(socket(UserSocket), "user_token", user2.id) + user_token2 = user_socket_token(user2) {:ok, socket2} = connect(UserSocket, %{"token" => user_token2}) - spectator_token = Phoenix.Token.sign(socket(UserSocket), "user_token", spectator.id) + spectator_token = user_socket_token(spectator) {:ok, spectator_socket} = connect(UserSocket, %{"token" => spectator_token}) {:ok, diff --git a/apps/codebattle/test/codebattle_web/channels/group_tournament_channel_test.exs b/apps/codebattle/test/codebattle_web/channels/group_tournament_channel_test.exs index d86e5d722..d4b22b678 100644 --- a/apps/codebattle/test/codebattle_web/channels/group_tournament_channel_test.exs +++ b/apps/codebattle/test/codebattle_web/channels/group_tournament_channel_test.exs @@ -35,7 +35,7 @@ defmodule CodebattleWeb.GroupTournamentChannelTest do # Grant the user access to the tournament Context.get_or_create(user, group_tournament) - user_token = Phoenix.Token.sign(socket(UserSocket), "user_token", user.id) + user_token = user_socket_token(user) {:ok, socket} = connect(UserSocket, %{"token" => user_token}) {:ok, %{socket: socket, topic: topic, user: user}} @@ -208,7 +208,7 @@ defmodule CodebattleWeb.GroupTournamentChannelTest do {:ok, _response, socket} = subscribe_and_join(socket, GroupTournamentChannel, topic) - other_token = Phoenix.Token.sign(socket(UserSocket), "user_token", other_user.id) + other_token = user_socket_token(other_user) {:ok, other_socket} = connect(UserSocket, %{"token" => other_token}) {:ok, _other_response, other_socket} = subscribe_and_join(other_socket, GroupTournamentChannel, topic) diff --git a/apps/codebattle/test/codebattle_web/channels/invite_channel_test.exs b/apps/codebattle/test/codebattle_web/channels/invite_channel_test.exs index 862f95dff..c9bd11ca8 100644 --- a/apps/codebattle/test/codebattle_web/channels/invite_channel_test.exs +++ b/apps/codebattle/test/codebattle_web/channels/invite_channel_test.exs @@ -12,8 +12,8 @@ defmodule CodebattleWeb.InviteChannelTest do insert(:task, level: "elementary") insert(:task, level: "elementary") - creator_token = Phoenix.Token.sign(socket(UserSocket), "user_token", creator.id) - recipient_token = Phoenix.Token.sign(socket(UserSocket), "user_token", recipient.id) + creator_token = user_socket_token(creator) + recipient_token = user_socket_token(recipient) {:ok, creator_socket} = connect(UserSocket, %{"token" => creator_token}) {:ok, recipient_socket} = connect(UserSocket, %{"token" => recipient_token}) diff --git a/apps/codebattle/test/codebattle_web/channels/lobby_channel_test.exs b/apps/codebattle/test/codebattle_web/channels/lobby_channel_test.exs index 0f125a293..2995bb146 100644 --- a/apps/codebattle/test/codebattle_web/channels/lobby_channel_test.exs +++ b/apps/codebattle/test/codebattle_web/channels/lobby_channel_test.exs @@ -17,7 +17,7 @@ defmodule CodebattleWeb.LobbyChannelTest do insert(:user_game, user: user, creator: false, game: game, result: "won") insert(:user_game, user: user, creator: true, game: game, result: "gave_up") - user_token = Phoenix.Token.sign(socket(UserSocket), "user_token", user.id) + user_token = user_socket_token(user) {:ok, socket} = connect(UserSocket, %{"token" => user_token}) {:ok, %{winner: user, socket: socket, task: task}} @@ -39,7 +39,7 @@ defmodule CodebattleWeb.LobbyChannelTest do test "creates game with other users" do user = insert(:user) - user_token = Phoenix.Token.sign(socket(UserSocket), "user_token", user.id) + user_token = user_socket_token(user) {:ok, socket} = connect(UserSocket, %{"token" => user_token}) {:ok, _payload, socket} = subscribe_and_join(socket, LobbyChannel, "lobby") @@ -68,7 +68,7 @@ defmodule CodebattleWeb.LobbyChannelTest do test "creates game with bot" do user = insert(:user) - user_token = Phoenix.Token.sign(socket(UserSocket), "user_token", user.id) + user_token = user_socket_token(user) insert(:task, level: "elementary", name: "2") {:ok, socket} = connect(UserSocket, %{"token" => user_token}) @@ -97,7 +97,7 @@ defmodule CodebattleWeb.LobbyChannelTest do insert(:task, level: "elementary", name: "2") - user_token = Phoenix.Token.sign(socket(UserSocket), "user_token", user1.id) + user_token = user_socket_token(user1) {:ok, socket} = connect(UserSocket, %{"token" => user_token}) {:ok, _payload, socket} = subscribe_and_join(socket, LobbyChannel, "lobby") @@ -115,7 +115,7 @@ defmodule CodebattleWeb.LobbyChannelTest do {:ok, game} = Game.Context.fetch_game(game_id) assert game.task.name == "1" - user_token = Phoenix.Token.sign(socket(UserSocket), "user_token", user2.id) + user_token = user_socket_token(user2) {:ok, socket} = connect(UserSocket, %{"token" => user_token}) {:ok, _payload, socket} = subscribe_and_join(socket, LobbyChannel, "lobby") @@ -152,7 +152,7 @@ defmodule CodebattleWeb.LobbyChannelTest do insert(:task, level: "elementary", name: "2") - user_token = Phoenix.Token.sign(socket(UserSocket), "user_token", user1.id) + user_token = user_socket_token(user1) {:ok, socket} = connect(UserSocket, %{"token" => user_token}) {:ok, _payload, socket} = subscribe_and_join(socket, LobbyChannel, "lobby") @@ -171,7 +171,7 @@ defmodule CodebattleWeb.LobbyChannelTest do {:ok, game} = Game.Context.fetch_game(game_id) assert game.task.name == "1" - user_token = Phoenix.Token.sign(socket(UserSocket), "user_token", user2.id) + user_token = user_socket_token(user2) {:ok, socket} = connect(UserSocket, %{"token" => user_token}) {:ok, _payload, socket} = subscribe_and_join(socket, LobbyChannel, "lobby") @@ -191,7 +191,7 @@ defmodule CodebattleWeb.LobbyChannelTest do {:ok, game} = Game.Context.fetch_game(game_id) assert game.task.name == "2" - user_token = Phoenix.Token.sign(socket(UserSocket), "user_token", user3.id) + user_token = user_socket_token(user3) {:ok, socket} = connect(UserSocket, %{"token" => user_token}) {:ok, _payload, socket} = subscribe_and_join(socket, LobbyChannel, "lobby") diff --git a/apps/codebattle/test/codebattle_web/channels/main_channel_test.exs b/apps/codebattle/test/codebattle_web/channels/main_channel_test.exs index cf56f56e9..1937d8679 100644 --- a/apps/codebattle/test/codebattle_web/channels/main_channel_test.exs +++ b/apps/codebattle/test/codebattle_web/channels/main_channel_test.exs @@ -12,8 +12,8 @@ defmodule CodebattleWeb.MainChannelTest do creator = insert(:user) recipient = insert(:user) - creator_token = Phoenix.Token.sign(socket(UserSocket), "user_token", creator.id) - recipient_token = Phoenix.Token.sign(socket(UserSocket), "user_token", recipient.id) + creator_token = user_socket_token(creator) + recipient_token = user_socket_token(recipient) {:ok, creator_socket} = connect(UserSocket, %{"token" => creator_token}) {:ok, recipient_socket} = connect(UserSocket, %{"token" => recipient_token}) diff --git a/apps/codebattle/test/codebattle_web/channels/tournament_admin_channel_auth_test.exs b/apps/codebattle/test/codebattle_web/channels/tournament_admin_channel_auth_test.exs index cb0a00254..82474fc51 100644 --- a/apps/codebattle/test/codebattle_web/channels/tournament_admin_channel_auth_test.exs +++ b/apps/codebattle/test/codebattle_web/channels/tournament_admin_channel_auth_test.exs @@ -22,7 +22,7 @@ defmodule CodebattleWeb.TournamentAdminChannelAuthTest do "players_limit" => 200 }) - creator_token = Phoenix.Token.sign(socket(UserSocket), "user_token", creator.id) + creator_token = user_socket_token(creator) {:ok, creator_socket} = connect(UserSocket, %{"token" => creator_token}) assert {:ok, _response, _socket} = @@ -53,7 +53,7 @@ defmodule CodebattleWeb.TournamentAdminChannelAuthTest do "players_limit" => 200 }) - moderator_token = Phoenix.Token.sign(socket(UserSocket), "user_token", moderator.id) + moderator_token = user_socket_token(moderator) {:ok, moderator_socket} = connect(UserSocket, %{"token" => moderator_token}) assert {:ok, _response, _socket} = diff --git a/apps/codebattle/test/codebattle_web/channels/tournament_admin_channel_test.exs b/apps/codebattle/test/codebattle_web/channels/tournament_admin_channel_test.exs index fbba1a6d7..f58e205b1 100644 --- a/apps/codebattle/test/codebattle_web/channels/tournament_admin_channel_test.exs +++ b/apps/codebattle/test/codebattle_web/channels/tournament_admin_channel_test.exs @@ -25,7 +25,7 @@ defmodule CodebattleWeb.TournamentAdminChannelTest do end defp join_admin(user, tournament_id) do - token = Phoenix.Token.sign(socket(UserSocket), "user_token", user.id) + token = user_socket_token(user) {:ok, socket} = connect(UserSocket, %{"token" => token}) {:ok, _payload, channel_socket} = diff --git a/apps/codebattle/test/codebattle_web/channels/tournament_channel_auth_test.exs b/apps/codebattle/test/codebattle_web/channels/tournament_channel_auth_test.exs index e4979ffaa..d60de40ee 100644 --- a/apps/codebattle/test/codebattle_web/channels/tournament_channel_auth_test.exs +++ b/apps/codebattle/test/codebattle_web/channels/tournament_channel_auth_test.exs @@ -27,7 +27,7 @@ defmodule CodebattleWeb.TournamentChannelAuthTest do "players_limit" => 200 }) - user_token = Phoenix.Token.sign(socket(UserSocket), "user_token", user.id) + user_token = user_socket_token(user) assert {:ok, user_socket} = connect(UserSocket, %{"token" => user_token, "access_token" => tournament.access_token}) @@ -70,7 +70,7 @@ defmodule CodebattleWeb.TournamentChannelAuthTest do avg_result_percent: Decimal.new("100.0") }) - user_token = Phoenix.Token.sign(socket(UserSocket), "user_token", user.id) + user_token = user_socket_token(user) assert {:ok, user_socket} = connect(UserSocket, %{"token" => user_token}) assert {:ok, _response, channel_socket} = diff --git a/apps/codebattle/test/codebattle_web/channels/tournament_channel_test.exs b/apps/codebattle/test/codebattle_web/channels/tournament_channel_test.exs index cc2adfe85..fe27dfbf1 100644 --- a/apps/codebattle/test/codebattle_web/channels/tournament_channel_test.exs +++ b/apps/codebattle/test/codebattle_web/channels/tournament_channel_test.exs @@ -21,10 +21,10 @@ # players_count: nil # ) -# creator_token = Phoenix.Token.sign(socket(UserSocket), "user_token", creator.id) +# creator_token = user_socket_token(creator) # {:ok, creator_socket} = connect(UserSocket, %{"token" => creator_token}) -# participant_token = Phoenix.Token.sign(socket(UserSocket), "user_token", participant.id) +# participant_token = user_socket_token(participant) # {:ok, participant_socket} = connect(UserSocket, %{"token" => participant_token}) # {:ok, diff --git a/apps/codebattle/test/codebattle_web/channels/tournament_stream_channel_test.exs b/apps/codebattle/test/codebattle_web/channels/tournament_stream_channel_test.exs index 191a49dfa..f40735d96 100644 --- a/apps/codebattle/test/codebattle_web/channels/tournament_stream_channel_test.exs +++ b/apps/codebattle/test/codebattle_web/channels/tournament_stream_channel_test.exs @@ -25,7 +25,7 @@ defmodule CodebattleWeb.TournamentStreamChannelTest do end defp connect_user(user) do - token = Phoenix.Token.sign(socket(UserSocket), "user_token", user.id) + token = user_socket_token(user) {:ok, socket} = connect(UserSocket, %{"token" => token}) socket end diff --git a/apps/codebattle/test/codebattle_web/channels/user_socket_test.exs b/apps/codebattle/test/codebattle_web/channels/user_socket_test.exs index 289026196..2cc5e4a02 100644 --- a/apps/codebattle/test/codebattle_web/channels/user_socket_test.exs +++ b/apps/codebattle/test/codebattle_web/channels/user_socket_test.exs @@ -5,7 +5,7 @@ defmodule CodebattleWeb.UserSocketTest do test "connect assigns access_token from socket params" do user = insert(:user) - user_token = Phoenix.Token.sign(socket(UserSocket), "user_token", user.id) + user_token = user_socket_token(user) assert {:ok, socket} = connect(UserSocket, %{"token" => user_token, "access_token" => "secret-token"}) @@ -15,10 +15,30 @@ defmodule CodebattleWeb.UserSocketTest do test "connect ignores blank access_token" do user = insert(:user) - user_token = Phoenix.Token.sign(socket(UserSocket), "user_token", user.id) + user_token = user_socket_token(user) assert {:ok, socket} = connect(UserSocket, %{"token" => user_token, "access_token" => " "}) assert socket.assigns.access_token == nil end + + test "connect accepts only an active user session" do + user = insert(:user) + {:ok, session, _raw_token} = Codebattle.UserSession.create(user) + current_token = Phoenix.Token.sign(socket(UserSocket), "user_token", {user.id, session.id}) + + assert {:ok, socket} = connect(UserSocket, %{"token" => current_token}) + assert socket.assigns.current_user.id == user.id + assert UserSocket.id(socket) == "user_session:#{session.id}" + + {:ok, _revoked_session} = Codebattle.UserSession.revoke_for_user(user.id, session.id) + assert :error = connect(UserSocket, %{"token" => current_token}) + end + + test "rejects legacy user id tokens" do + user = insert(:user) + legacy_token = Phoenix.Token.sign(socket(UserSocket), "user_token", user.id) + + assert :error = connect(UserSocket, %{"token" => legacy_token}) + end end diff --git a/apps/codebattle/test/codebattle_web/controllers/admin/group_task_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/admin/group_task_controller_test.exs index 9648ac1d0..f133ace0c 100644 --- a/apps/codebattle/test/codebattle_web/controllers/admin/group_task_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/admin/group_task_controller_test.exs @@ -21,7 +21,7 @@ defmodule CodebattleWeb.Admin.GroupTaskControllerTest do conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> get("/admin/group_tasks") assert html_response(conn, 200) =~ "Group Tasks" @@ -33,7 +33,7 @@ defmodule CodebattleWeb.Admin.GroupTaskControllerTest do conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> post("/admin/group_tasks", %{ "group_task" => %{"slug" => "spring-final", "time_to_solve_sec" => "900"} }) @@ -52,7 +52,7 @@ defmodule CodebattleWeb.Admin.GroupTaskControllerTest do conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> delete("/admin/group_tasks/#{group_task.id}/solutions/#{solution.id}") assert redirected_to(conn) == "/admin/group_tasks/#{group_task.id}" @@ -67,7 +67,7 @@ defmodule CodebattleWeb.Admin.GroupTaskControllerTest do conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> patch("/admin/group_tasks/#{group_task.id}/solutions/#{solution.id}", %{ "group_task_solution" => %{ "lang" => "python", diff --git a/apps/codebattle/test/codebattle_web/controllers/admin/group_tournament_json_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/admin/group_tournament_json_controller_test.exs index b31b23269..83c482438 100644 --- a/apps/codebattle/test/codebattle_web/controllers/admin/group_tournament_json_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/admin/group_tournament_json_controller_test.exs @@ -40,7 +40,7 @@ defmodule CodebattleWeb.Admin.GroupTournamentJsonControllerTest do conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> get("/admin/group_tournaments/#{gt.id}/history.json") assert response_content_type(conn, :json) @@ -57,7 +57,7 @@ defmodule CodebattleWeb.Admin.GroupTournamentJsonControllerTest do conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> get("/admin/group_tournaments/#{gt.id}/history.json") assert json_response(conn, 200) == %{"who" => "newer"} @@ -113,7 +113,7 @@ defmodule CodebattleWeb.Admin.GroupTournamentJsonControllerTest do conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> get("/admin/group_tournaments/#{gt.id}/history.json") assert json_response(conn, 404) == %{"error" => "NOT_FOUND"} @@ -127,7 +127,7 @@ defmodule CodebattleWeb.Admin.GroupTournamentJsonControllerTest do conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> get("/admin/group_tournaments/#{gt.id}/history.json") assert conn |> get_resp_header("content-disposition") |> hd() =~ "attachment;" diff --git a/apps/codebattle/test/codebattle_web/controllers/admin_feedback_live_test.exs b/apps/codebattle/test/codebattle_web/controllers/admin_feedback_live_test.exs index ad9ed0470..25dce464c 100644 --- a/apps/codebattle/test/codebattle_web/controllers/admin_feedback_live_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/admin_feedback_live_test.exs @@ -9,7 +9,7 @@ defmodule CodebattleWeb.AdminFeedbackLiveTest do conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> get("/admin/feedback") assert conn.status == 200 @@ -26,7 +26,7 @@ defmodule CodebattleWeb.AdminFeedbackLiveTest do conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> get("/admin") assert conn.status == 200 @@ -57,7 +57,7 @@ defmodule CodebattleWeb.AdminFeedbackLiveTest do conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> get("/admin/games") assert conn.status == 200 @@ -71,7 +71,7 @@ defmodule CodebattleWeb.AdminFeedbackLiveTest do conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> get("/admin/events") assert conn.status == 200 @@ -85,7 +85,7 @@ defmodule CodebattleWeb.AdminFeedbackLiveTest do conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> get("/admin/code-checks") assert conn.status == 200 @@ -112,7 +112,7 @@ defmodule CodebattleWeb.AdminFeedbackLiveTest do conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> get("/admin/code-checks?tab=failures") body = response(conn, 200) @@ -127,7 +127,7 @@ defmodule CodebattleWeb.AdminFeedbackLiveTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/admin/feedback") assert conn.status == 302 diff --git a/apps/codebattle/test/codebattle_web/controllers/admin_user_show_live_test.exs b/apps/codebattle/test/codebattle_web/controllers/admin_user_show_live_test.exs index 4711f065f..6f3f7e514 100644 --- a/apps/codebattle/test/codebattle_web/controllers/admin_user_show_live_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/admin_user_show_live_test.exs @@ -12,7 +12,7 @@ defmodule CodebattleWeb.AdminUserShowLiveTest do html = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> get(Routes.admin_user_show_view_path(conn, :show, user.id)) |> html_response(200) @@ -26,7 +26,7 @@ defmodule CodebattleWeb.AdminUserShowLiveTest do html = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> get(Routes.admin_user_show_view_path(conn, :show, user.id)) |> html_response(200) @@ -88,7 +88,7 @@ defmodule CodebattleWeb.AdminUserShowLiveTest do conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> get(Routes.admin_user_show_view_path(conn, :show, user.id)) body = html_response(conn, 200) diff --git a/apps/codebattle/test/codebattle_web/controllers/api/v1/activity_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/api/v1/activity_controller_test.exs index 39917d486..5c104de8b 100644 --- a/apps/codebattle/test/codebattle_web/controllers/api/v1/activity_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/api/v1/activity_controller_test.exs @@ -23,7 +23,7 @@ defmodule CodebattleWeb.Api.V1.ActivityControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.api_v1_activity_path(conn, :show, user.id)) response = json_response(conn, 200) @@ -54,7 +54,7 @@ defmodule CodebattleWeb.Api.V1.ActivityControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.api_v1_activity_path(conn, :show, user.id, year: selected_year)) response = json_response(conn, 200) @@ -85,7 +85,7 @@ defmodule CodebattleWeb.Api.V1.ActivityControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.api_v1_activity_path(conn, :show, user.id, year: current_year)) response = json_response(conn, 200) diff --git a/apps/codebattle/test/codebattle_web/controllers/api/v1/event/leaderboard_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/api/v1/event/leaderboard_controller_test.exs index 41deefa74..c96354034 100644 --- a/apps/codebattle/test/codebattle_web/controllers/api/v1/event/leaderboard_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/api/v1/event/leaderboard_controller_test.exs @@ -38,7 +38,7 @@ defmodule CodebattleWeb.Api.V1.Event.LeaderboardControllerTest do } do conn = conn - |> put_session(:user_id, user1_id) + |> log_in_user(user1_id) |> get("/api/v1/events/#{event.id}/leaderboard", %{"type" => "clan"}) response = json_response(conn, 200) @@ -86,7 +86,7 @@ defmodule CodebattleWeb.Api.V1.Event.LeaderboardControllerTest do } do conn = conn - |> put_session(:user_id, user1_id) + |> log_in_user(user1_id) |> get("/api/v1/events/#{event.id}/leaderboard", %{"type" => "player"}) response = json_response(conn, 200) @@ -141,7 +141,7 @@ defmodule CodebattleWeb.Api.V1.Event.LeaderboardControllerTest do } do conn = conn - |> put_session(:user_id, user1_id) + |> log_in_user(user1_id) |> get("/api/v1/events/#{event.id}/leaderboard", %{ "type" => "player_clan", "clan_id" => clan1_id @@ -188,7 +188,7 @@ defmodule CodebattleWeb.Api.V1.Event.LeaderboardControllerTest do } do conn = conn - |> put_session(:user_id, user1_id) + |> log_in_user(user1_id) |> get("/api/v1/events/#{event.id}/leaderboard", %{ "type" => "random" }) @@ -267,7 +267,7 @@ defmodule CodebattleWeb.Api.V1.Event.LeaderboardControllerTest do } do conn = conn - |> put_session(:user_id, user1_id) + |> log_in_user(user1_id) |> get("/api/v1/events/#{event.id}/leaderboard", %{"type" => "clan"}) response = json_response(conn, 200) @@ -315,7 +315,7 @@ defmodule CodebattleWeb.Api.V1.Event.LeaderboardControllerTest do } do conn = conn - |> put_session(:user_id, user1_id) + |> log_in_user(user1_id) |> get("/api/v1/events/#{event.id}/leaderboard", %{"type" => "player"}) response = json_response(conn, 200) @@ -370,7 +370,7 @@ defmodule CodebattleWeb.Api.V1.Event.LeaderboardControllerTest do } do conn = conn - |> put_session(:user_id, user1_id) + |> log_in_user(user1_id) |> get("/api/v1/events/#{event.id}/leaderboard", %{ "type" => "player_clan", "clan_id" => clan1_id @@ -417,7 +417,7 @@ defmodule CodebattleWeb.Api.V1.Event.LeaderboardControllerTest do } do conn = conn - |> put_session(:user_id, user1_id) + |> log_in_user(user1_id) |> get("/api/v1/events/#{event.id}/leaderboard", %{ "type" => "random" }) @@ -595,7 +595,7 @@ defmodule CodebattleWeb.Api.V1.Event.LeaderboardControllerTest do } do conn = conn - |> put_session(:user_id, user1_id) + |> log_in_user(user1_id) |> get("/public_api/v1/events/#{event.id}/leaderboard", %{ "type" => "random" }) diff --git a/apps/codebattle/test/codebattle_web/controllers/api/v1/feedback_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/api/v1/feedback_controller_test.exs index 7cf78d90f..b4975c352 100644 --- a/apps/codebattle/test/codebattle_web/controllers/api/v1/feedback_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/api/v1/feedback_controller_test.exs @@ -14,7 +14,7 @@ defmodule CodebattleWeb.Api.V1.FeedbackControllerTest do response = conn - |> put_session(:user_id, u1.id) + |> log_in_user(u1.id) |> get(Routes.api_v1_feedback_path(conn, :index)) |> json_response(200) @@ -52,7 +52,7 @@ defmodule CodebattleWeb.Api.V1.FeedbackControllerTest do response = conn - |> put_session(:user_id, u1.id) + |> log_in_user(u1.id) |> post(Routes.api_v1_feedback_path(conn, :index), params) |> json_response(201) diff --git a/apps/codebattle/test/codebattle_web/controllers/api/v1/group_tournament_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/api/v1/group_tournament_controller_test.exs index ac493a4c9..f220cf920 100644 --- a/apps/codebattle/test/codebattle_web/controllers/api/v1/group_tournament_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/api/v1/group_tournament_controller_test.exs @@ -76,7 +76,7 @@ defmodule CodebattleWeb.Api.V1.GroupTournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> post("/api/v1/group_tournaments/#{group_tournament.id}/submit_solution", %{ "solution" => "print(1)" }) diff --git a/apps/codebattle/test/codebattle_web/controllers/api/v1/settings_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/api/v1/settings_controller_test.exs index d9c380c08..350df199b 100644 --- a/apps/codebattle/test/codebattle_web/controllers/api/v1/settings_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/api/v1/settings_controller_test.exs @@ -2,6 +2,11 @@ defmodule CodebattleWeb.Api.V1.SettingsControllerTest do use CodebattleWeb.ConnCase, async: false alias Codebattle.Repo + alias Codebattle.User + alias Codebattle.UserSession + + @old_password "old-password-secure!" + @new_password "new-password-secure!" describe "#show" do test "shows current user settings", %{conn: conn} do @@ -22,7 +27,7 @@ defmodule CodebattleWeb.Api.V1.SettingsControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.api_v1_settings_path(conn, :show)) assert json_response(conn, 200) == %{ @@ -37,7 +42,8 @@ defmodule CodebattleWeb.Api.V1.SettingsControllerTest do "db_type" => "mongodb", "style_lang" => "less", "github_id" => 1, - "github_name" => "g_name" + "github_name" => "g_name", + "has_password" => false } end end @@ -60,15 +66,16 @@ defmodule CodebattleWeb.Api.V1.SettingsControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> patch(Routes.api_v1_settings_path(conn, :update, new_settings)) assert json_response(conn, 200) == new_settings |> Map.put("clan", "Bca") + |> Map.put("has_password", false) |> Map.put("can_unlink_social", true) - updated = Repo.get!(Codebattle.User, user.id) + updated = Repo.get!(User, user.id) assert updated.sound_settings.level == 3 assert updated.sound_settings.tournament_level == 8 @@ -87,12 +94,12 @@ defmodule CodebattleWeb.Api.V1.SettingsControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> patch(Routes.api_v1_settings_path(conn, :update, new_settings)) assert json_response(conn, 422) == %{"errors" => %{"name" => ["can't be blank"]}} - updated = Repo.get!(Codebattle.User, user.id) + updated = Repo.get!(User, user.id) assert updated.name == user.name end @@ -104,10 +111,160 @@ defmodule CodebattleWeb.Api.V1.SettingsControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> patch(Routes.api_v1_settings_path(conn, :show, new_settings)) assert json_response(conn, 422) == %{"errors" => %{"name" => ["has already been taken"]}} end end + + describe "#update_password" do + test "updates the password, rotates the session, and rejects the previous password", %{conn: conn} do + user = insert_user_with_password() + conn = log_in_user(conn, user, %{user_agent: "Current browser", ip: "127.0.0.1"}) + previous_token = get_session(conn, :user_session_token) + current_session = UserSession.get_active_by_token(previous_token) + {:ok, other_session, other_token} = UserSession.create(user, %{user_agent: "Other browser"}) + + conn = + patch(conn, "/api/v1/settings/password", %{ + "current_password" => @old_password, + "password" => @new_password, + "password_confirmation" => @new_password + }) + + assert json_response(conn, 200) == %{"status" => "ok", "has_password" => true} + + renewed_token = get_session(conn, :user_session_token) + renewed_session = UserSession.get_active_by_token(renewed_token) + + refute renewed_token == previous_token + assert renewed_session.id == current_session.id + assert renewed_session.user_agent == "Current browser" + assert UserSession.get_active_by_token(previous_token) == nil + assert UserSession.get_active_by_token(other_token) == nil + assert Repo.get!(UserSession, other_session.id).revoked_at + assert %User{id: user_id} = User.authenticate(user.name, @new_password) + assert user_id == user.id + refute User.authenticate(user.name, @old_password) + end + + test "rejects an invalid current password and passwords outside bcrypt limits", %{conn: conn} do + user = insert_user_with_password() + conn = log_in_user(conn, user.id) + + invalid_current_password = + patch(conn, "/api/v1/settings/password", %{ + "current_password" => "wrong-password", + "password" => @new_password, + "password_confirmation" => @new_password + }) + + assert json_response(invalid_current_password, 422) == %{ + "errors" => %{"current_password" => ["is invalid"]} + } + + too_short = + patch(conn, "/api/v1/settings/password", %{ + "current_password" => @old_password, + "password" => "too-short", + "password_confirmation" => "too-short" + }) + + assert "should be at least 12 character(s)" in json_response(too_short, 422)["errors"]["password"] + + over_bcrypt_limit = String.duplicate("é", 37) + + too_long = + patch(conn, "/api/v1/settings/password", %{ + "current_password" => @old_password, + "password" => over_bcrypt_limit, + "password_confirmation" => over_bcrypt_limit + }) + + assert "should be at most 72 bytes" in json_response(too_long, 422)["errors"]["password"] + assert Repo.get!(User, user.id).password_hash == user.password_hash + end + + test "rate limits repeated failed password changes", %{conn: conn} do + user = insert_user_with_password() + conn = log_in_user(conn, user.id) + + Enum.each(1..5, fn _attempt -> + response = + patch(conn, "/api/v1/settings/password", %{ + "current_password" => "wrong-password", + "password" => @new_password, + "password_confirmation" => @new_password + }) + + assert response.status == 422 + end) + + response = + patch(conn, "/api/v1/settings/password", %{ + "current_password" => @old_password, + "password" => @new_password, + "password_confirmation" => @new_password + }) + + assert json_response(response, 429) == %{ + "errors" => %{"current_password" => ["too many attempts, try again later"]} + } + end + end + + describe "#sessions" do + test "lists active sessions and marks the current one", %{conn: conn} do + user = insert(:user) + conn = log_in_user(conn, user, %{user_agent: "Current browser", ip: "127.0.0.1"}) + {:ok, other_session, _token} = UserSession.create(user, %{user_agent: "Other browser", ip: "10.0.0.2"}) + + conn = get(conn, "/api/v1/settings/sessions") + sessions = json_response(conn, 200)["sessions"] + + assert length(sessions) == 2 + + assert %{"current" => true, "user_agent" => "Current browser", "ip" => "127.0.0.1"} = + Enum.find(sessions, & &1["current"]) + + other_session_id = other_session.id + + assert %{"current" => false, "id" => ^other_session_id, "user_agent" => "Other browser"} = + Enum.find(sessions, &(&1["id"] == other_session.id)) + end + + test "revokes another session but cannot revoke another user's session", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + conn = log_in_user(conn, user) + {:ok, session, raw_token} = UserSession.create(user) + {:ok, foreign_session, _foreign_token} = UserSession.create(other_user) + + response = delete(conn, "/api/v1/settings/sessions/#{session.id}") + + assert json_response(response, 200) == %{"status" => "ok", "current" => false} + assert UserSession.get_active_by_token(raw_token) == nil + + response = delete(conn, "/api/v1/settings/sessions/#{foreign_session.id}") + assert json_response(response, 404) == %{"error" => "session not found"} + end + + test "revokes the current session and drops its cookie", %{conn: conn} do + user = insert(:user) + conn = log_in_user(conn, user) + token = get_session(conn, :user_session_token) + session = UserSession.get_active_by_token(token) + + conn = delete(conn, "/api/v1/settings/sessions/#{session.id}") + + assert json_response(conn, 200) == %{"status" => "ok", "current" => true} + assert get_session(conn, :user_session_token) == nil + assert UserSession.get_active_by_token(token) == nil + end + end + + defp insert_user_with_password do + insert(:user, password_hash: Bcrypt.hash_pwd_salt(@old_password)) + end end diff --git a/apps/codebattle/test/codebattle_web/controllers/api/v1/stream_config_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/api/v1/stream_config_controller_test.exs index 59e9da62f..975d0c06b 100644 --- a/apps/codebattle/test/codebattle_web/controllers/api/v1/stream_config_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/api/v1/stream_config_controller_test.exs @@ -5,7 +5,7 @@ defmodule CodebattleWeb.Api.V1.StreamConfigControllerTest do setup %{conn: conn} do user = :user |> insert() |> Repo.reload() - conn = put_session(conn, :user_id, user.id) + conn = log_in_user(conn, user.id) {:ok, %{conn: conn, user: user}} end diff --git a/apps/codebattle/test/codebattle_web/controllers/api/v1/task_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/api/v1/task_controller_test.exs index a28c3c5a0..a72819008 100644 --- a/apps/codebattle/test/codebattle_web/controllers/api/v1/task_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/api/v1/task_controller_test.exs @@ -22,7 +22,7 @@ defmodule CodebattleWeb.Api.V1.TaskControllerTest do tasks = conn - |> put_session(:user_id, u1.id) + |> log_in_user(u1.id) |> get(Routes.api_v1_task_path(conn, :index)) |> json_response(200) |> Map.get("tasks") @@ -32,6 +32,8 @@ defmodule CodebattleWeb.Api.V1.TaskControllerTest do %{ "creator_id" => u1.id, "id" => t1.id, + "description_en" => "test sum", + "description_ru" => "проверка суммы", "level" => "easy", "name" => "1", "origin" => "user", @@ -40,6 +42,8 @@ defmodule CodebattleWeb.Api.V1.TaskControllerTest do %{ "creator_id" => u2.id, "id" => t2.id, + "description_en" => "test sum", + "description_ru" => "проверка суммы", "level" => "easy", "name" => "2", "origin" => "user", @@ -48,6 +52,8 @@ defmodule CodebattleWeb.Api.V1.TaskControllerTest do %{ "creator_id" => nil, "id" => t3.id, + "description_en" => "test sum", + "description_ru" => "проверка суммы", "level" => "easy", "name" => "3", "origin" => "user", @@ -103,7 +109,7 @@ defmodule CodebattleWeb.Api.V1.TaskControllerTest do response = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.api_v1_task_path(conn, :show, hidden_task.id)) |> json_response(200) diff --git a/apps/codebattle/test/codebattle_web/controllers/api/v1/tournament_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/api/v1/tournament_controller_test.exs index 93dad2af7..b6ad24412 100644 --- a/apps/codebattle/test/codebattle_web/controllers/api/v1/tournament_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/api/v1/tournament_controller_test.exs @@ -10,7 +10,7 @@ defmodule CodebattleWeb.Api.V1.TournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.api_v1_tournament_path(conn, :index)) assert json_response(conn, 200) == %{"season_tournaments" => [], "user_tournaments" => []} @@ -43,7 +43,7 @@ defmodule CodebattleWeb.Api.V1.TournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.api_v1_tournament_path(conn, :index)) assert %{ @@ -83,7 +83,7 @@ defmodule CodebattleWeb.Api.V1.TournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.api_v1_tournament_path(conn, :index), %{"from" => from_date}) assert %{ @@ -119,7 +119,7 @@ defmodule CodebattleWeb.Api.V1.TournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.api_v1_tournament_path(conn, :index), %{"to" => to_date}) assert %{ @@ -164,7 +164,7 @@ defmodule CodebattleWeb.Api.V1.TournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.api_v1_tournament_path(conn, :index), %{ "from" => from_date, "to" => to_date @@ -221,7 +221,7 @@ defmodule CodebattleWeb.Api.V1.TournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.api_v1_tournament_path(conn, :index), %{ "from" => from_date, "to" => to_date @@ -253,7 +253,7 @@ defmodule CodebattleWeb.Api.V1.TournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.api_v1_tournament_path(conn, :index), %{"from" => "invalid-date"}) # Should fall back to default behavior (current time as from date) @@ -278,7 +278,7 @@ defmodule CodebattleWeb.Api.V1.TournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.api_v1_tournament_path(conn, :index), %{"to" => "invalid-date"}) # Should fall back to default behavior (30 days from now as to date) @@ -307,7 +307,7 @@ defmodule CodebattleWeb.Api.V1.TournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.api_v1_tournament_path(conn, :index), %{ "from" => date_string, "to" => date_string @@ -339,7 +339,7 @@ defmodule CodebattleWeb.Api.V1.TournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.api_v1_tournament_path(conn, :index), %{ "from" => from_date, "to" => to_date @@ -364,7 +364,7 @@ defmodule CodebattleWeb.Api.V1.TournamentControllerTest do conn = conn - |> put_session(:user_id, guest_user.id) + |> log_in_user(guest_user.id) |> get(Routes.api_v1_tournament_path(conn, :index), %{ "from" => from_date, "to" => to_date @@ -411,7 +411,7 @@ defmodule CodebattleWeb.Api.V1.TournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.api_v1_tournament_path(conn, :index), %{ "from" => from_date, "to" => to_date @@ -458,7 +458,7 @@ defmodule CodebattleWeb.Api.V1.TournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.api_v1_tournament_path(conn, :index)) assert %{ @@ -487,7 +487,7 @@ defmodule CodebattleWeb.Api.V1.TournamentControllerTest do conn = conn - |> put_session(:user_id, moderator.id) + |> log_in_user(moderator.id) |> put( Routes.api_v1_tournament_path(conn, :update, tournament.id), %{ @@ -525,7 +525,7 @@ defmodule CodebattleWeb.Api.V1.TournamentControllerTest do conn = conn - |> put_session(:user_id, moderator.id) + |> log_in_user(moderator.id) |> put( Routes.api_v1_tournament_path(conn, :update, tournament.id), %{ @@ -570,7 +570,7 @@ defmodule CodebattleWeb.Api.V1.TournamentControllerTest do conn = conn - |> put_session(:user_id, creator.id) + |> log_in_user(creator.id) |> put( Routes.api_v1_tournament_path(conn, :update, tournament.id), %{ @@ -619,7 +619,7 @@ defmodule CodebattleWeb.Api.V1.TournamentControllerTest do conn = conn - |> put_session(:user_id, creator.id) + |> log_in_user(creator.id) |> put( Routes.api_v1_tournament_path(conn, :update, tournament.id), %{ @@ -649,7 +649,7 @@ defmodule CodebattleWeb.Api.V1.TournamentControllerTest do conn = conn - |> put_session(:user_id, moderator.id) + |> log_in_user(moderator.id) |> get(Routes.api_v1_tournament_path(conn, :index)) assert %{"user_tournaments" => user_tournaments} = json_response(conn, 200) diff --git a/apps/codebattle/test/codebattle_web/controllers/api/v1/user_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/api/v1/user_controller_test.exs index 1fcde61ea..24631d5f8 100644 --- a/apps/codebattle/test/codebattle_web/controllers/api/v1/user_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/api/v1/user_controller_test.exs @@ -424,7 +424,7 @@ defmodule CodebattleWeb.Api.V1.UserControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.api_v1_user_path(conn, :current)) resp_body = json_response(conn, 200) diff --git a/apps/codebattle/test/codebattle_web/controllers/api/v1/user_game_report_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/api/v1/user_game_report_controller_test.exs index 701d58572..4cbe9345b 100644 --- a/apps/codebattle/test/codebattle_web/controllers/api/v1/user_game_report_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/api/v1/user_game_report_controller_test.exs @@ -28,7 +28,7 @@ defmodule CodebattleWeb.Api.V1.UserGameReportControllerTest do response = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> post(Routes.api_v1_user_game_report_path(conn, :create, game.id), params) |> json_response(201) @@ -72,7 +72,7 @@ defmodule CodebattleWeb.Api.V1.UserGameReportControllerTest do response = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> post(Routes.api_v1_user_game_report_path(conn, :create, game.id), params) |> json_response(422) @@ -98,7 +98,7 @@ defmodule CodebattleWeb.Api.V1.UserGameReportControllerTest do response = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> post(Routes.api_v1_user_game_report_path(conn, :create, game.id), params) |> json_response(422) @@ -124,7 +124,7 @@ defmodule CodebattleWeb.Api.V1.UserGameReportControllerTest do response = conn - |> put_session(:user_id, other_user.id) + |> log_in_user(other_user.id) |> post(Routes.api_v1_user_game_report_path(conn, :create, game.id), params) |> json_response(403) @@ -148,7 +148,7 @@ defmodule CodebattleWeb.Api.V1.UserGameReportControllerTest do response = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> post(Routes.api_v1_user_game_report_path(conn, :create, game.id), params) |> json_response(422) @@ -173,7 +173,7 @@ defmodule CodebattleWeb.Api.V1.UserGameReportControllerTest do response = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> post(Routes.api_v1_user_game_report_path(conn, :create, game.id), params) |> json_response(422) diff --git a/apps/codebattle/test/codebattle_web/controllers/auth_bind_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/auth_bind_controller_test.exs index 23c008c68..9b221a0b0 100644 --- a/apps/codebattle/test/codebattle_web/controllers/auth_bind_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/auth_bind_controller_test.exs @@ -35,7 +35,7 @@ defmodule CodebattleWeb.AuthBindControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/auth/github/callback/bind", %{"code" => "asfd"}) user = Repo.reload(user) @@ -59,7 +59,7 @@ defmodule CodebattleWeb.AuthBindControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/auth/discord/callback/bind", %{"code" => "asfd"}) user = Repo.reload(user) @@ -88,7 +88,7 @@ defmodule CodebattleWeb.AuthBindControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/auth/github/callback/bind", %{"code" => "expired-code"}) assert conn.state == :sent @@ -100,7 +100,7 @@ defmodule CodebattleWeb.AuthBindControllerTest do describe "DELETE /auth/:provider/" do test "unbinds discord", %{conn: conn} do user = insert(:user) - conn = put_session(conn, :user_id, user.id) + conn = log_in_user(conn, user.id) delete(conn, "/auth/discord") user = Repo.reload!(user) @@ -112,7 +112,7 @@ defmodule CodebattleWeb.AuthBindControllerTest do test "unbinds github", %{conn: conn} do user = insert(:user) - conn = put_session(conn, :user_id, user.id) + conn = log_in_user(conn, user.id) delete(conn, "/auth/github") user = Repo.reload!(user) @@ -136,7 +136,7 @@ defmodule CodebattleWeb.AuthBindControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> delete("/auth/github") user = Repo.reload!(user) diff --git a/apps/codebattle/test/codebattle_web/controllers/clan_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/clan_controller_test.exs index f11831b8f..59670bd6f 100644 --- a/apps/codebattle/test/codebattle_web/controllers/clan_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/clan_controller_test.exs @@ -4,7 +4,7 @@ defmodule CodebattleWeb.ClanControllerTest do alias Codebattle.Clan defp signed_conn(conn, user) do - put_session(conn, :user_id, user.id) + log_in_user(conn, user.id) end defp position(html, value) do diff --git a/apps/codebattle/test/codebattle_web/controllers/dev_login_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/dev_login_controller_test.exs new file mode 100644 index 000000000..505e5c44c --- /dev/null +++ b/apps/codebattle/test/codebattle_web/controllers/dev_login_controller_test.exs @@ -0,0 +1,26 @@ +defmodule CodebattleWeb.DevLoginControllerTest do + use CodebattleWeb.ConnCase, async: true + + alias Codebattle.UserSession + + test "creates a user session that is shown in settings", %{conn: conn} do + conn = + conn + |> put_req_header("user-agent", "Codebattle development browser") + |> post("/auth/dev_login", %{"subscription_type" => "free"}) + + assert redirected_to(conn) == "/" + + token = get_session(conn, :user_session_token) + session = UserSession.get_active_by_token(token) + + assert session.user_agent == "Codebattle development browser" + assert session.user_id == session.user.id + + conn = get(conn, "/settings") + response = html_response(conn, 200) + + assert response =~ "user_sessions" + assert response =~ "Codebattle development browser" + end +end diff --git a/apps/codebattle/test/codebattle_web/controllers/event_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/event_controller_test.exs index db85e84e3..f9edbb932 100644 --- a/apps/codebattle/test/codebattle_web/controllers/event_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/event_controller_test.exs @@ -7,7 +7,7 @@ defmodule CodebattleWeb.EventControllerTest do conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> get("/admin/events") assert html_response(conn, 200) =~ event.title diff --git a/apps/codebattle/test/codebattle_web/controllers/game_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/game_controller_test.exs index bbf857adb..85c596be0 100644 --- a/apps/codebattle/test/codebattle_web/controllers/game_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/game_controller_test.exs @@ -93,7 +93,7 @@ defmodule CodebattleWeb.GameControllerTest do conn = conn - |> put_session(:user_id, current_user.id) + |> log_in_user(current_user.id) |> get(Routes.game_path(conn, :show, game.id)) assert html_response(conn, 200) @@ -133,7 +133,7 @@ defmodule CodebattleWeb.GameControllerTest do Game.Context.create_game(%{state: "waiting_opponent", players: users, task: task}) conn - |> put_session(:user_id, user1.id) + |> log_in_user(user1.id) |> delete(Routes.game_path(conn, :delete, game.id)) |> html_response(302) @@ -143,6 +143,34 @@ defmodule CodebattleWeb.GameControllerTest do end end + describe "POST /games/create_by_task" do + test "keeps the training game in the lobby snapshot after returning home", %{conn: conn} do + user = insert(:user) + task = insert(:task, state: "active", visibility: "public") + conn = log_in_user(conn, user.id) + + create_conn = + post(conn, Routes.game_path(conn, :create_by_task), %{ + "task_id" => task.id + }) + + %{"game_id" => game_id} = json_response(create_conn, 200) + on_exit(fn -> Game.Context.terminate_game(game_id) end) + + assert {:ok, game} = Game.Context.fetch_game(game_id) + assert game.mode == "standard" + assert game.is_bot + assert game.use_chat + assert game.visibility_type == "hidden" + assert game.task.id == task.id + + lobby_conn = get(conn, Routes.root_path(conn, :index)) + + assert %{"active_games" => active_games} = inertia_props(lobby_conn) + assert Enum.any?(active_games, &(&1.id == game_id)) + end + end + describe "POST /games/:id/join" do test "joins game", %{conn: conn} do task = insert(:task, level: "elementary") @@ -153,7 +181,7 @@ defmodule CodebattleWeb.GameControllerTest do Game.Context.create_game(%{state: "waiting_opponent", players: [user1], task: task}) conn - |> put_session(:user_id, user2.id) + |> log_in_user(user2.id) |> post(Routes.game_path(conn, :join, game.id)) |> html_response(302) diff --git a/apps/codebattle/test/codebattle_web/controllers/group_tournament_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/group_tournament_controller_test.exs index e363a433c..b224f3835 100644 --- a/apps/codebattle/test/codebattle_web/controllers/group_tournament_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/group_tournament_controller_test.exs @@ -53,7 +53,7 @@ defmodule CodebattleWeb.GroupTournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/my-tournament") assert redirected_to(conn) == "/" @@ -75,7 +75,7 @@ defmodule CodebattleWeb.GroupTournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/my-tournament") assert redirected_to(conn) == "/group_tournaments/#{group_tournament.id}" @@ -96,7 +96,7 @@ defmodule CodebattleWeb.GroupTournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/my-tournament") assert redirected_to(conn) == "/group_tournaments/#{group_tournament.id}" @@ -110,7 +110,7 @@ defmodule CodebattleWeb.GroupTournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/my-tournament") assert redirected_to(conn) == "/" @@ -123,7 +123,7 @@ defmodule CodebattleWeb.GroupTournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/my-tournament") assert redirected_to(conn) == "/group_tournaments/#{group_tournament.id}" @@ -142,7 +142,7 @@ defmodule CodebattleWeb.GroupTournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/my-tournament?start=true") assert redirected_to(conn) == "/group_tournaments/#{group_tournament.id}" @@ -176,7 +176,7 @@ defmodule CodebattleWeb.GroupTournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/my-tournament?start=true") assert redirected_to(conn) == "/group_tournaments/#{group_tournament.id}" @@ -206,7 +206,7 @@ defmodule CodebattleWeb.GroupTournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/my-tournament?start=true") assert redirected_to(conn) == "/group_tournaments/#{group_tournament.id}" @@ -236,7 +236,7 @@ defmodule CodebattleWeb.GroupTournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/my-tournament") assert redirected_to(conn) == "/group_tournaments/#{newer.id}" diff --git a/apps/codebattle/test/codebattle_web/controllers/inertia_pages_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/inertia_pages_controller_test.exs index effd904ca..c5b77bf08 100644 --- a/apps/codebattle/test/codebattle_web/controllers/inertia_pages_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/inertia_pages_controller_test.exs @@ -28,7 +28,7 @@ defmodule CodebattleWeb.InertiaPagesControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.head_to_head_path(conn, :show, user.id, opponent.id)) assert inertia_component(conn) == "HeadToHead" diff --git a/apps/codebattle/test/codebattle_web/controllers/locale_test.exs b/apps/codebattle/test/codebattle_web/controllers/locale_test.exs index 649233214..9fc5257e3 100644 --- a/apps/codebattle/test/codebattle_web/controllers/locale_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/locale_test.exs @@ -16,7 +16,7 @@ defmodule CodebattleWeb.LocaleTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.root_path(conn, :index)) assert get_session(conn, :locale) == "ru" @@ -33,7 +33,7 @@ defmodule CodebattleWeb.LocaleTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.root_path(conn, :index)) assert get_session(conn, :locale) == "en" @@ -45,7 +45,7 @@ defmodule CodebattleWeb.LocaleTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.root_path(conn, :index)) assert get_session(conn, :locale) == "ru" @@ -57,7 +57,7 @@ defmodule CodebattleWeb.LocaleTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.root_path(conn, :index)) assert get_session(conn, :locale) == "ru" diff --git a/apps/codebattle/test/codebattle_web/controllers/public_event_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/public_event_controller_test.exs index b0707778e..0c9749a9b 100644 --- a/apps/codebattle/test/codebattle_web/controllers/public_event_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/public_event_controller_test.exs @@ -27,7 +27,7 @@ defmodule CodebattleWeb.PublicEventControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.public_event_path(conn, :show, event.slug)) response = html_response(conn, 200) @@ -57,7 +57,7 @@ defmodule CodebattleWeb.PublicEventControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.public_event_path(conn, :show, event.slug)) assert redirected_to(conn) == Routes.root_path(conn, :index) @@ -115,7 +115,7 @@ defmodule CodebattleWeb.PublicEventControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> post(Routes.public_event_path(conn, :stage, event.slug, %{stage_slug: "q"})) assert [db_tournament] = Repo.all(Tournament) @@ -161,7 +161,7 @@ defmodule CodebattleWeb.PublicEventControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> post(Routes.public_event_path(conn, :stage, event.slug, %{stage_slug: "q"})) assert redirected_to(conn) == Routes.public_event_path(conn, :show, event.slug) @@ -213,7 +213,7 @@ defmodule CodebattleWeb.PublicEventControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> post(Routes.public_event_path(conn, :stage, event.slug, %{stage_slug: "global-q"})) assert redirected_to(conn) == Routes.tournament_path(conn, :show, tournament.id) @@ -247,7 +247,7 @@ defmodule CodebattleWeb.PublicEventControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> post(Routes.public_event_path(conn, :stage, event.slug, %{stage_slug: "q"})) assert redirected_to(conn) == Routes.public_event_path(conn, :show, event.slug) diff --git a/apps/codebattle/test/codebattle_web/controllers/root_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/root_controller_test.exs index e827e1ec5..065400b88 100644 --- a/apps/codebattle/test/codebattle_web/controllers/root_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/root_controller_test.exs @@ -13,7 +13,7 @@ defmodule Codebattle.RootControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.user_path(conn, :index)) assert conn.status == 200 @@ -24,7 +24,7 @@ defmodule Codebattle.RootControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.root_path(conn, :index)) assert html_response(conn, 200) diff --git a/apps/codebattle/test/codebattle_web/controllers/session_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/session_controller_test.exs index c9c42fec2..67ab2f1ac 100644 --- a/apps/codebattle/test/codebattle_web/controllers/session_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/session_controller_test.exs @@ -16,7 +16,7 @@ defmodule CodebattleWeb.SessionControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.session_path(conn, :new)) assert redirected_to(conn) == "/" @@ -27,7 +27,7 @@ defmodule CodebattleWeb.SessionControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.session_path(conn, :new, next: "/settings")) assert redirected_to(conn) == "/settings" diff --git a/apps/codebattle/test/codebattle_web/controllers/task_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/task_controller_test.exs index dfcc97a20..00e9c59db 100644 --- a/apps/codebattle/test/codebattle_web/controllers/task_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/task_controller_test.exs @@ -10,7 +10,7 @@ defmodule CodebattleWeb.TaskControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.task_path(conn, :index)) assert conn.status == 200 @@ -37,7 +37,7 @@ defmodule CodebattleWeb.TaskControllerTest do # user can see public tasks new_conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.task_path(conn, :show, visible_task.id)) assert new_conn.status == 200 @@ -57,7 +57,7 @@ defmodule CodebattleWeb.TaskControllerTest do # user can't see hidden tasks new_conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.task_path(conn, :show, hidden_task.id)) assert new_conn.status == 404 @@ -65,7 +65,7 @@ defmodule CodebattleWeb.TaskControllerTest do # user can see his hidden tasks new_conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.task_path(conn, :show, hidden_created_task.id)) assert new_conn.status == 200 @@ -73,7 +73,7 @@ defmodule CodebattleWeb.TaskControllerTest do # admin can see hidden tasks new_conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> get(Routes.task_path(conn, :show, hidden_task.id)) assert new_conn.status == 200 @@ -86,14 +86,14 @@ defmodule CodebattleWeb.TaskControllerTest do new_conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> patch(Routes.task_activate_path(conn, :activate, task)) assert new_conn.status == 404 new_conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> patch(Routes.task_activate_path(conn, :activate, task)) assert redirected_to(new_conn) == Routes.task_path(conn, :index) @@ -110,14 +110,14 @@ defmodule CodebattleWeb.TaskControllerTest do new_conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> patch(Routes.task_disable_path(conn, :disable, task)) assert new_conn.status == 404 new_conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> patch(Routes.task_disable_path(conn, :disable, task)) assert redirected_to(new_conn) == Routes.task_path(conn, :index) @@ -135,7 +135,7 @@ defmodule CodebattleWeb.TaskControllerTest do # unrelated user new_conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> delete(Routes.task_path(conn, :delete, task)) assert new_conn.status == 404 @@ -143,7 +143,7 @@ defmodule CodebattleWeb.TaskControllerTest do # admin or creator new_conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> delete(Routes.task_path(conn, :delete, task)) assert redirected_to(new_conn) == Routes.task_path(conn, :index) @@ -154,7 +154,7 @@ defmodule CodebattleWeb.TaskControllerTest do # unrelated user new_conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> delete(Routes.task_path(conn, :delete, task)) assert new_conn.status == 404 @@ -162,7 +162,7 @@ defmodule CodebattleWeb.TaskControllerTest do # admin or creator new_conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> delete(Routes.task_path(conn, :delete, task)) assert new_conn.status == 404 diff --git a/apps/codebattle/test/codebattle_web/controllers/task_pack_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/task_pack_controller_test.exs index fbfd8b8e0..8574544c8 100644 --- a/apps/codebattle/test/codebattle_web/controllers/task_pack_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/task_pack_controller_test.exs @@ -7,7 +7,7 @@ defmodule CodebattleWeb.TaskPackControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.task_pack_path(conn, :index)) assert conn.status == 200 @@ -28,7 +28,7 @@ defmodule CodebattleWeb.TaskPackControllerTest do # user can see public tasks new_conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.task_pack_path(conn, :show, visible_task_pack)) assert new_conn.status == 200 @@ -36,7 +36,7 @@ defmodule CodebattleWeb.TaskPackControllerTest do # user can't see hidden tasks new_conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.task_pack_path(conn, :show, hidden_task_pack.id)) assert new_conn.status == 404 @@ -44,7 +44,7 @@ defmodule CodebattleWeb.TaskPackControllerTest do # user can see his hidden tasks new_conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.task_pack_path(conn, :show, hidden_created_task_pack.id)) assert new_conn.status == 200 @@ -52,7 +52,7 @@ defmodule CodebattleWeb.TaskPackControllerTest do # admin can see hidden tasks new_conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> get(Routes.task_pack_path(conn, :show, hidden_task_pack.id)) assert new_conn.status == 200 @@ -63,7 +63,7 @@ defmodule CodebattleWeb.TaskPackControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.task_pack_path(conn, :new)) assert html_response(conn, 200) =~ "Create your own task pack" @@ -75,7 +75,7 @@ defmodule CodebattleWeb.TaskPackControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.task_pack_path(conn, :edit, task_pack)) assert html_response(conn, 200) =~ "Edit task pack" @@ -92,7 +92,7 @@ defmodule CodebattleWeb.TaskPackControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> post(Routes.task_pack_path(conn, :create), task_pack: params) assert %{id: id} = redirected_params(conn) @@ -113,6 +113,53 @@ defmodule CodebattleWeb.TaskPackControllerTest do } = task_pack end + test ".create validates malformed and out-of-range task ids", %{conn: conn} do + user = insert(:user) + conn = log_in_user(conn, user.id) + + Enum.each( + [ + {"It is string", "Please provide only integers with comma separated values"}, + {nil, "Please provide only integers with comma separated values"}, + {["1", "2"], "Please provide only integers with comma separated values"}, + {"0, 1", "Please provide integers between 1 and 2147483647 with comma separated values"}, + {"2147483648", "Please provide integers between 1 and 2147483647 with comma separated values"} + ], + fn {task_ids, error} -> + response = + conn + |> post(Routes.task_pack_path(conn, :create), + task_pack: %{ + "name" => "pack_#{System.unique_integer([:positive])}", + "task_ids" => task_ids, + "visibility" => "public" + } + ) + |> html_response(200) + + assert response =~ error + end + ) + end + + test ".create accepts the largest PostgreSQL integer task id", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> log_in_user(user.id) + |> post(Routes.task_pack_path(conn, :create), + task_pack: %{ + "name" => "max_task_id_pack", + "task_ids" => "2147483647", + "visibility" => "public" + } + ) + + assert %{id: id} = redirected_params(conn) + assert %{task_ids: [2_147_483_647]} = Codebattle.TaskPack.get!(id) + end + test ".update", %{conn: conn} do user = insert(:user) task_pack = insert(:task_pack, creator_id: user.id) @@ -125,7 +172,7 @@ defmodule CodebattleWeb.TaskPackControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> patch(Routes.task_pack_path(conn, :update, task_pack), task_pack: params) assert %{id: id} = redirected_params(conn) @@ -136,6 +183,26 @@ defmodule CodebattleWeb.TaskPackControllerTest do assert %{name: "new_mega_task_pack", task_ids: [22]} = task_pack end + test ".update rejects malformed task ids without changing the task pack", %{conn: conn} do + user = insert(:user) + task_pack = insert(:task_pack, creator_id: user.id, task_ids: [1, 2]) + + response = + conn + |> log_in_user(user.id) + |> patch(Routes.task_pack_path(conn, :update, task_pack), + task_pack: %{ + "name" => task_pack.name, + "task_ids" => "1, nope", + "visibility" => "public" + } + ) + |> html_response(200) + + assert response =~ "Please provide only integers with comma separated values" + assert Codebattle.TaskPack.get!(task_pack.id).task_ids == [1, 2] + end + test ".activate", %{conn: conn} do user = insert(:user) admin = insert(:admin) @@ -143,14 +210,14 @@ defmodule CodebattleWeb.TaskPackControllerTest do new_conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> patch(Routes.task_pack_activate_path(conn, :activate, task_pack)) assert new_conn.status == 404 new_conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> patch(Routes.task_pack_activate_path(conn, :activate, task_pack)) assert redirected_to(new_conn) == Routes.task_pack_path(conn, :index) @@ -167,14 +234,14 @@ defmodule CodebattleWeb.TaskPackControllerTest do new_conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> patch(Routes.task_pack_disable_path(conn, :disable, task_pack)) assert new_conn.status == 404 new_conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> patch(Routes.task_pack_disable_path(conn, :disable, task_pack)) assert redirected_to(new_conn) == Routes.task_pack_path(conn, :index) @@ -192,7 +259,7 @@ defmodule CodebattleWeb.TaskPackControllerTest do # unrelated user new_conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> delete(Routes.task_pack_path(conn, :delete, task_pack)) assert new_conn.status == 404 @@ -200,7 +267,7 @@ defmodule CodebattleWeb.TaskPackControllerTest do # admin or creator new_conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> delete(Routes.task_pack_path(conn, :delete, task_pack)) assert redirected_to(new_conn) == Routes.task_pack_path(conn, :index) diff --git a/apps/codebattle/test/codebattle_web/controllers/tournament/stream_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/tournament/stream_controller_test.exs index 3881130c7..a131b518c 100644 --- a/apps/codebattle/test/codebattle_web/controllers/tournament/stream_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/tournament/stream_controller_test.exs @@ -32,7 +32,7 @@ defmodule CodebattleWeb.Tournament.StreamControllerTest do conn = conn - |> put_session(:user_id, creator.id) + |> log_in_user(creator.id) |> get("/tournaments/#{tournament.id}/stream") assert conn.status == 200 @@ -47,7 +47,7 @@ defmodule CodebattleWeb.Tournament.StreamControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/tournaments/#{tournament.id}/stream") assert conn.status == 404 @@ -68,7 +68,7 @@ defmodule CodebattleWeb.Tournament.StreamControllerTest do conn = conn - |> put_session(:user_id, creator.id) + |> log_in_user(creator.id) |> get("/admin/tournaments/#{tournament.id}/stream") body = html_response(conn, 200) @@ -85,7 +85,7 @@ defmodule CodebattleWeb.Tournament.StreamControllerTest do conn = conn - |> put_session(:user_id, moderator.id) + |> log_in_user(moderator.id) |> get("/admin/tournaments/#{tournament.id}/stream") assert conn.status == 200 @@ -98,7 +98,7 @@ defmodule CodebattleWeb.Tournament.StreamControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/admin/tournaments/#{tournament.id}/stream") assert conn.status == 404 @@ -119,7 +119,7 @@ defmodule CodebattleWeb.Tournament.StreamControllerTest do conn = conn - |> put_session(:user_id, creator.id) + |> log_in_user(creator.id) |> get("/admin/tournaments/#{tournament.id}/stream/state") body = json_response(conn, 200) @@ -137,7 +137,7 @@ defmodule CodebattleWeb.Tournament.StreamControllerTest do conn = conn - |> put_session(:user_id, creator.id) + |> log_in_user(creator.id) |> get("/admin/tournaments/#{tournament.id}/stream/state") assert %{"active_game_id" => 4242} = json_response(conn, 200) @@ -150,7 +150,7 @@ defmodule CodebattleWeb.Tournament.StreamControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/admin/tournaments/#{tournament.id}/stream/state") assert %{"error" => "NOT_FOUND"} = json_response(conn, 404) diff --git a/apps/codebattle/test/codebattle_web/controllers/tournament_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/tournament_controller_test.exs index b48449239..cea8366c6 100644 --- a/apps/codebattle/test/codebattle_web/controllers/tournament_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/tournament_controller_test.exs @@ -10,7 +10,7 @@ defmodule CodebattleWeb.TournamentControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.tournament_path(conn, :index)) assert conn.status == 200 @@ -43,7 +43,7 @@ defmodule CodebattleWeb.TournamentControllerTest do new_conn = conn - |> put_session(:user_id, admin.id) + |> log_in_user(admin.id) |> get(Routes.tournament_path(conn, :show, tournament.id)) assert new_conn.status == 200 @@ -56,21 +56,21 @@ defmodule CodebattleWeb.TournamentControllerTest do new_conn = conn - |> put_session(:user_id, creator.id) + |> log_in_user(creator.id) |> get(Routes.tournament_path(conn, :show, tournament.id)) assert new_conn.status == 200 new_conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.tournament_path(conn, :show, tournament.id)) assert new_conn.status == 200 new_conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.tournament_path(conn, :show, tournament.id, access_token: tournament.access_token)) assert new_conn.status == 200 @@ -118,7 +118,7 @@ defmodule CodebattleWeb.TournamentControllerTest do assert_raise Ecto.NoResultsError, fn -> conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.tournament_path(conn, :show, 12_313_221)) end end @@ -146,7 +146,7 @@ defmodule CodebattleWeb.TournamentControllerTest do new_conn = conn - |> put_session(:user_id, creator.id) + |> log_in_user(creator.id) |> get(Routes.tournament_path(conn, :show, tournament.id)) assert new_conn.status == 200 diff --git a/apps/codebattle/test/codebattle_web/controllers/tournaments_schedule_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/tournaments_schedule_controller_test.exs index 5d03daee6..5bb33700b 100644 --- a/apps/codebattle/test/codebattle_web/controllers/tournaments_schedule_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/tournaments_schedule_controller_test.exs @@ -8,7 +8,7 @@ defmodule CodebattleWeb.TournamentsScheduleControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.tournaments_schedule_path(conn, :index)) assert inertia_component(conn) == "TournamentsSchedule" @@ -29,7 +29,7 @@ defmodule CodebattleWeb.TournamentsScheduleControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.tournaments_schedule_path(conn, :index)) version = conn.private.inertia_version diff --git a/apps/codebattle/test/codebattle_web/controllers/user_controller_test.exs b/apps/codebattle/test/codebattle_web/controllers/user_controller_test.exs index a059c5054..fe222f6f7 100644 --- a/apps/codebattle/test/codebattle_web/controllers/user_controller_test.exs +++ b/apps/codebattle/test/codebattle_web/controllers/user_controller_test.exs @@ -6,7 +6,7 @@ defmodule CodebattleWeb.UserControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.user_path(conn, :index)) assert conn.status == 200 @@ -34,7 +34,7 @@ defmodule CodebattleWeb.UserControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.user_path(conn, :show, user.id)) assert conn.status == 200 @@ -54,7 +54,7 @@ defmodule CodebattleWeb.UserControllerTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(Routes.user_setting_path(conn, :edit)) assert conn.status == 200 diff --git a/apps/codebattle/test/codebattle_web/integration/game/forbid_multiple_games_test.exs b/apps/codebattle/test/codebattle_web/integration/game/forbid_multiple_games_test.exs index aef3c4cfe..1e731e791 100644 --- a/apps/codebattle/test/codebattle_web/integration/game/forbid_multiple_games_test.exs +++ b/apps/codebattle/test/codebattle_web/integration/game/forbid_multiple_games_test.exs @@ -7,7 +7,7 @@ defmodule CodebattleWeb.Integration.ForbidMultipleGamesTest do socket = socket(UserSocket, "user_id", %{current_user: user}) conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get(user_path(conn, :index)) {:ok, _response, socket} = subscribe_and_join(socket, LobbyChannel, "lobby") diff --git a/apps/codebattle/test/codebattle_web/integration/game/game_with_reporting_test.exs b/apps/codebattle/test/codebattle_web/integration/game/game_with_reporting_test.exs index 593d409a0..358dcd0e5 100644 --- a/apps/codebattle/test/codebattle_web/integration/game/game_with_reporting_test.exs +++ b/apps/codebattle/test/codebattle_web/integration/game/game_with_reporting_test.exs @@ -6,7 +6,7 @@ defmodule CodebattleWeb.Integration.Game.GameWithReportingTest do user1 = insert(:user, %{name: "first", email: "test1@test.test", github_id: 1, rating: 1000}) user2 = insert(:user, %{name: "second", email: "test2@test.test", github_id: 2, rating: 1000}) - conn2 = put_session(conn, :user_id, user2.id) + conn2 = log_in_user(conn, user2.id) socket1 = socket(UserSocket, "user_id", %{user_id: user1.id, current_user: user1}) socket2 = socket(UserSocket, "user_id", %{user_id: user2.id, current_user: user2}) diff --git a/apps/codebattle/test/codebattle_web/integration/game/give_up_test.exs b/apps/codebattle/test/codebattle_web/integration/game/give_up_test.exs index 93223fab6..47d45ed50 100644 --- a/apps/codebattle/test/codebattle_web/integration/game/give_up_test.exs +++ b/apps/codebattle/test/codebattle_web/integration/game/give_up_test.exs @@ -6,7 +6,7 @@ defmodule CodebattleWeb.Integration.Game.GiveUpTest do user1 = insert(:user) user2 = insert(:user) - conn2 = put_session(conn, :user_id, user2.id) + conn2 = log_in_user(conn, user2.id) socket1 = socket(UserSocket, "user_id", %{user_id: user1.id, current_user: user1}) socket2 = socket(UserSocket, "user_id", %{user_id: user2.id, current_user: user2}) diff --git a/apps/codebattle/test/codebattle_web/integration/game/rematch_test.exs b/apps/codebattle/test/codebattle_web/integration/game/rematch_test.exs index 0f8425ab9..4a269eb15 100644 --- a/apps/codebattle/test/codebattle_web/integration/game/rematch_test.exs +++ b/apps/codebattle/test/codebattle_web/integration/game/rematch_test.exs @@ -10,8 +10,8 @@ defmodule CodebattleWeb.Integration.Game.RematchTest do user1 = insert(:user) user2 = insert(:user) - conn1 = put_session(conn, :user_id, user1.id) - conn2 = put_session(conn, :user_id, user2.id) + conn1 = log_in_user(conn, user1.id) + conn2 = log_in_user(conn, user2.id) socket1 = socket(UserSocket, "user_id", %{user_id: user1.id, current_user: user1}) socket2 = socket(UserSocket, "user_id", %{user_id: user2.id, current_user: user2}) diff --git a/apps/codebattle/test/codebattle_web/integration/game/standard_game_test.exs b/apps/codebattle/test/codebattle_web/integration/game/standard_game_test.exs index 8b200f186..8d3a8bcfb 100644 --- a/apps/codebattle/test/codebattle_web/integration/game/standard_game_test.exs +++ b/apps/codebattle/test/codebattle_web/integration/game/standard_game_test.exs @@ -7,9 +7,9 @@ defmodule CodebattleWeb.Integration.Game.StandardGameTest do user2 = insert(:user, %{name: "second", email: "test2@test.test", github_id: 2, rating: 1000}) user3 = insert(:user, %{name: "other", email: "test3@test.test", github_id: 3, rating: 1000}) - conn1 = put_session(conn, :user_id, user1.id) - conn2 = put_session(conn, :user_id, user2.id) - conn3 = put_session(conn, :user_id, user3.id) + conn1 = log_in_user(conn, user1.id) + conn2 = log_in_user(conn, user2.id) + conn3 = log_in_user(conn, user3.id) socket1 = socket(UserSocket, "user_id", %{user_id: user1.id, current_user: user1}) socket2 = socket(UserSocket, "user_id", %{user_id: user2.id, current_user: user2}) diff --git a/apps/codebattle/test/codebattle_web/integration/game/timeout_test.exs b/apps/codebattle/test/codebattle_web/integration/game/timeout_test.exs index 90795f4b3..750aa7448 100644 --- a/apps/codebattle/test/codebattle_web/integration/game/timeout_test.exs +++ b/apps/codebattle/test/codebattle_web/integration/game/timeout_test.exs @@ -9,7 +9,7 @@ defmodule CodebattleWeb.Integration.Game.TimeoutTest do user1 = insert(:user) user2 = insert(:user) - conn2 = put_session(conn, :user_id, user2.id) + conn2 = log_in_user(conn, user2.id) socket1 = socket(UserSocket, "user_id", %{user_id: user1.id, current_user: user1}) socket2 = socket(UserSocket, "user_id", %{user_id: user2.id, current_user: user2}) diff --git a/apps/codebattle/test/codebattle_web/live/admin/clan_index_view_test.exs b/apps/codebattle/test/codebattle_web/live/admin/clan_index_view_test.exs index e08bcc776..23899eae5 100644 --- a/apps/codebattle/test/codebattle_web/live/admin/clan_index_view_test.exs +++ b/apps/codebattle/test/codebattle_web/live/admin/clan_index_view_test.exs @@ -5,7 +5,7 @@ defmodule CodebattleWeb.Live.Admin.ClanIndexViewTest do defp signed_conn(conn) do admin = insert(:admin) - put_session(conn, :user_id, admin.id) + log_in_user(conn, admin.id) end test "renders clans", %{conn: conn} do diff --git a/apps/codebattle/test/codebattle_web/live/admin/tournament_stream_view_test.exs b/apps/codebattle/test/codebattle_web/live/admin/tournament_stream_view_test.exs index 17fdd29ba..7900a3df2 100644 --- a/apps/codebattle/test/codebattle_web/live/admin/tournament_stream_view_test.exs +++ b/apps/codebattle/test/codebattle_web/live/admin/tournament_stream_view_test.exs @@ -23,7 +23,7 @@ defmodule CodebattleWeb.Live.Admin.TournamentStreamViewTest do end defp signed_conn(conn, user) do - put_session(conn, :user_id, user.id) + log_in_user(conn, user.id) end describe "mount" do diff --git a/apps/codebattle/test/codebattle_web/live/admin/user_index_view_test.exs b/apps/codebattle/test/codebattle_web/live/admin/user_index_view_test.exs index d5d1bb125..a351e99ac 100644 --- a/apps/codebattle/test/codebattle_web/live/admin/user_index_view_test.exs +++ b/apps/codebattle/test/codebattle_web/live/admin/user_index_view_test.exs @@ -5,7 +5,7 @@ defmodule CodebattleWeb.Live.Admin.UserIndexViewTest do defp signed_conn(conn) do admin = insert(:admin) - put_session(conn, :user_id, admin.id) + log_in_user(conn, admin.id) end test "admin can update user clan", %{conn: conn} do diff --git a/apps/codebattle/test/codebattle_web/plugs/assign_current_user_test.exs b/apps/codebattle/test/codebattle_web/plugs/assign_current_user_test.exs index ecfad2a9d..fc40eca20 100644 --- a/apps/codebattle/test/codebattle_web/plugs/assign_current_user_test.exs +++ b/apps/codebattle/test/codebattle_web/plugs/assign_current_user_test.exs @@ -1,24 +1,52 @@ defmodule CodebattleWeb.Plugs.AssignCurrentUserTest do use CodebattleWeb.ConnCase, async: true - test "clears session and redirects for html when session user does not exist", %{conn: conn} do + test "requires a new login for a legacy browser session", %{conn: conn} do + user = insert(:user) + conn = conn - |> put_session(:user_id, 1_000_000) + |> put_session(:user_id, user.id) |> get(Routes.root_path(conn, :index)) assert conn.status == 302 assert get_session(conn, :user_id) == nil end - test "clears session and assigns guest for api when session user does not exist", %{conn: conn} do + test "clears a legacy API session and assigns a guest", %{conn: conn} do + user = insert(:user) + conn = conn - |> put_session(:user_id, 1_000_000) + |> put_session(:user_id, user.id) |> get(Routes.api_v1_user_path(conn, :current)) assert json_response(conn, 200) == %{"id" => 0} assert get_session(conn, :user_id) == nil assert conn.assigns.current_user.is_guest end + + test "clears a revoked browser session", %{conn: conn} do + user = insert(:user) + conn = log_in_user(conn, user) + token = get_session(conn, :user_session_token) + :ok = Codebattle.UserSession.revoke_by_token(token) + + conn = get(conn, Routes.root_path(conn, :index)) + + assert conn.status == 302 + assert get_session(conn, :user_session_token) == nil + end + + test "treats a revoked API session as a guest", %{conn: conn} do + user = insert(:user) + conn = log_in_user(conn, user) + token = get_session(conn, :user_session_token) + :ok = Codebattle.UserSession.revoke_by_token(token) + + conn = get(conn, Routes.api_v1_user_path(conn, :current)) + + assert json_response(conn, 200) == %{"id" => 0} + assert get_session(conn, :user_session_token) == nil + end end diff --git a/apps/codebattle/test/codebattle_web/plugs/restrict_access_test.exs b/apps/codebattle/test/codebattle_web/plugs/restrict_access_test.exs index 78f2111e4..69ba6e3bc 100644 --- a/apps/codebattle/test/codebattle_web/plugs/restrict_access_test.exs +++ b/apps/codebattle/test/codebattle_web/plugs/restrict_access_test.exs @@ -19,7 +19,7 @@ defmodule CodebattleWeb.Plugs.RescrictAccessTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/tournaments/2") assert conn.status != 302 @@ -31,7 +31,7 @@ defmodule CodebattleWeb.Plugs.RescrictAccessTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/tournaments/23") assert conn.status != 302 @@ -42,7 +42,7 @@ defmodule CodebattleWeb.Plugs.RescrictAccessTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/api/v1/user/123/stats") assert conn.status != 302 @@ -53,7 +53,7 @@ defmodule CodebattleWeb.Plugs.RescrictAccessTest do conn = conn - |> put_session(:user_id, user.id) + |> log_in_user(user.id) |> get("/api/v1/user/current") assert conn.status != 302 diff --git a/apps/codebattle/test/support/channel_case.ex b/apps/codebattle/test/support/channel_case.ex index f219fd67a..31dc3e4f1 100644 --- a/apps/codebattle/test/support/channel_case.ex +++ b/apps/codebattle/test/support/channel_case.ex @@ -17,6 +17,7 @@ defmodule CodebattleWeb.ChannelCase do using do quote do + import CodebattleWeb.ChannelCase, only: [user_socket_token: 1] import CodebattleWeb.Factory # Import conveniences for testing with channels import Phoenix.ChannelTest @@ -36,4 +37,9 @@ defmodule CodebattleWeb.ChannelCase do Codebattle.DataCase.setup_sandbox(tags) :ok end + + def user_socket_token(user) do + {:ok, token} = Codebattle.UserSession.create_socket_token(user) + token + end end diff --git a/apps/codebattle/test/support/conn_case.ex b/apps/codebattle/test/support/conn_case.ex index e8f20eeb8..111ce9ab5 100644 --- a/apps/codebattle/test/support/conn_case.ex +++ b/apps/codebattle/test/support/conn_case.ex @@ -25,6 +25,7 @@ defmodule CodebattleWeb.ConnCase do using do quote do import Codebattle.OauthTestHelpers + import CodebattleWeb.ConnCase, only: [log_in_user: 2, log_in_user: 3] import CodebattleWeb.Factory import Phoenix.ConnTest import Phoenix.LiveViewTest @@ -56,4 +57,15 @@ defmodule CodebattleWeb.ConnCase do {:ok, conn: conn} end + + def log_in_user(conn, user_or_id, attrs \\ %{}) do + user = + case user_or_id do + %Codebattle.User{} = user -> user + user_id -> Codebattle.User.get!(user_id) + end + + {:ok, _session, token} = Codebattle.UserSession.create(user, attrs) + Plug.Conn.put_session(conn, :user_session_token, token) + end end diff --git a/mix.exs b/mix.exs index be8d122a9..3352507cf 100644 --- a/mix.exs +++ b/mix.exs @@ -14,6 +14,10 @@ defmodule CodebattleUmbrella.MixProject do {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, {:excoveralls, "~> 0.13", only: :test} ], + hex: [ + # Remove these acknowledgements when cowlib publishes a fixed release. + ignore_advisories: ["CVE-2026-43966", "CVE-2026-43969"] + ], listeners: [Phoenix.CodeReloader], dialyzer: [ paths: [