Reject a second live WebSocket for the same session name.
Two tabs with the cookie already set never POST /api/session, so name-online on login did not cover the design rule. Claim the name under the same lock as the online check. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fetchSchools, type SchoolsResponse } from '../net/api.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { MainMenu } from './mainMenu.ts';
|
||||
|
||||
vi.mock('../net/api.ts', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../net/api.ts')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchSchools: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('MainMenu ownership', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('hides delete on another owners card and shows it on an ownerless one', async () => {
|
||||
vi.mocked(fetchSchools).mockResolvedValue(menuState());
|
||||
|
||||
const menu = new MainMenu({
|
||||
onOpenSchool: () => {},
|
||||
onLogout: () => {},
|
||||
});
|
||||
document.body.append(menu.element);
|
||||
await menu.refresh();
|
||||
|
||||
const foreign = menu.element.querySelector('[data-school-id="2"]');
|
||||
const orphan = menu.element.querySelector('[data-school-id="3"]');
|
||||
expect((foreign?.querySelector('.button--danger') as HTMLElement).hidden).toBe(true);
|
||||
expect((orphan?.querySelector('.button--danger') as HTMLElement).hidden).toBe(false);
|
||||
expect(foreign?.textContent).toContain('Bob');
|
||||
expect(orphan?.textContent).toContain(t('schoolOwnerless'));
|
||||
});
|
||||
});
|
||||
|
||||
function menuState(): SchoolsResponse {
|
||||
return {
|
||||
maxSchools: 2,
|
||||
maxSchoolsTotal: 16,
|
||||
defaultStartDate: '2012-03-31T06:00:00.000Z',
|
||||
gameMinutesPerRealSecond: 1,
|
||||
schoolWeekDays: 5,
|
||||
schools: [],
|
||||
others: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Foreign',
|
||||
gameTime: '2012-03-31T06:00:00.000Z',
|
||||
running: true,
|
||||
speedIndex: 1,
|
||||
seed: 1,
|
||||
owner: 'Bob',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Orphan',
|
||||
gameTime: '2012-03-31T06:00:00.000Z',
|
||||
running: true,
|
||||
speedIndex: 1,
|
||||
seed: 2,
|
||||
owner: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace HSchool.Server.Net;
|
||||
internal sealed class ClientRegistry
|
||||
{
|
||||
private readonly ConcurrentDictionary<uint, GameClient> _clients = new();
|
||||
private readonly object _names = new();
|
||||
private uint _nextPlayerId;
|
||||
|
||||
public int Count => _clients.Count;
|
||||
@@ -28,14 +29,40 @@ internal sealed class ClientRegistry
|
||||
|
||||
public bool IsUserNameOnline(string normalizedUserName)
|
||||
{
|
||||
foreach (var client in _clients.Values)
|
||||
lock (_names)
|
||||
{
|
||||
if (client.UserName is null)
|
||||
return HasUserName(normalizedUserName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One live socket per name. Checked under the same lock as <see cref="IsUserNameOnline"/>
|
||||
/// so a second tab cannot sneak a Welcome in between the HTTP check and SetUserName.
|
||||
/// </summary>
|
||||
public bool TryClaimUserName(GameClient client, string userName)
|
||||
{
|
||||
lock (_names)
|
||||
{
|
||||
if (HasUserName(userName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
client.SetUserName(userName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasUserName(string normalizedUserName)
|
||||
{
|
||||
foreach (var existing in _clients.Values)
|
||||
{
|
||||
if (existing.UserName is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(client.NormalizedUserName, normalizedUserName, StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(existing.NormalizedUserName, normalizedUserName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ internal sealed class GameSocketHandler(
|
||||
public async Task HandleAsync(WebSocket socket, string userName, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = clients.Add(socket);
|
||||
client.SetUserName(userName);
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(ProtocolConstants.MaxMessageSize);
|
||||
using var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
|
||||
@@ -32,6 +31,16 @@ internal sealed class GameSocketHandler(
|
||||
|
||||
try
|
||||
{
|
||||
if (!clients.TryClaimUserName(client, userName))
|
||||
{
|
||||
await CloseAsync(
|
||||
socket,
|
||||
WebSocketCloseStatus.PolicyViolation,
|
||||
"Name is already online.",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(connectionCts.Token);
|
||||
handshakeCts.CancelAfter(HandshakeTimeout);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user