AI Implementation feature(913): Admin Area Players Management 1.00 (#57)

This commit was merged in pull request #57.
This commit is contained in:
2026-07-23 08:43:46 +00:00
parent e4ccf0fe13
commit d468cca766
11 changed files with 674 additions and 53 deletions
+53
View File
@@ -453,4 +453,57 @@ describe('Admin /players endpoints (Job 867)', () => {
const stillThere = await ds.getRepository(UserEntity).findOne({ where: { id: admin.id } });
expect(stillThere).not.toBeNull();
});
it('two-admin concurrent role demotion: one succeeds, the other receives 409 LAST_ADMIN (Job 913)', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'original', password: 'Sup3rSecret!Pass' })
.expect(201);
const adminAuth = await loginAs(app, 'original', 'Sup3rSecret!Pass');
const adminAgent = request.agent(app.getHttpServer());
await adminAgent.get('/api/v1/auth/csrf');
const adminCsrfCookies: any = adminAgent.jar.getCookies(CookieAccessInfo.All);
const adminCsrf = adminCsrfCookies.find((c: any) => c.name === 'csrf')?.value;
await adminAgent
.post('/api/v1/admin/users')
.set('Authorization', `Bearer ${adminAuth.accessToken}`)
.set('X-CSRF-Token', adminCsrf)
.send({ username: 'bob', password: 'Sup3rSecret!Pass', role: 'admin' })
.expect(201);
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${adminAuth.accessToken}`)
.expect(200);
const originalRow = list.body.find((u: any) => u.username === 'original');
const bobRow = list.body.find((u: any) => u.username === 'bob');
expect(originalRow.role).toBe('admin');
expect(bobRow.role).toBe('admin');
const bobAuth = await loginAs(app, 'bob', 'Sup3rSecret!Pass');
const first = await adminAuth.agent
.patch(`/api/v1/admin/players/${originalRow.id}/role`)
.set('Authorization', `Bearer ${adminAuth.accessToken}`)
.set('X-CSRF-Token', adminAuth.csrf)
.send({ role: 'player' })
.expect(200)
.expect((r) => expect(r.body.role).toBe('player'));
const second = await bobAuth.agent
.patch(`/api/v1/admin/players/${bobRow.id}/role`)
.set('Authorization', `Bearer ${bobAuth.accessToken}`)
.set('X-CSRF-Token', bobAuth.csrf)
.send({ role: 'player' })
.expect(409)
.expect((r) => expect(r.body.code).toBe('LAST_ADMIN'));
const ds = app.get(DataSource);
const rowsAfter = await ds.getRepository(UserEntity).find();
const adminCount = rowsAfter.filter((u) => u.role === 'admin').length;
expect(adminCount).toBe(1);
const remainingAdmin = rowsAfter.find((u) => u.role === 'admin');
expect(remainingAdmin?.username).toBe('bob');
});
});
+106
View File
@@ -1,10 +1,17 @@
import {
CROSS_TAB_CHANNEL_NAME,
CROSS_TAB_STORAGE_KEY,
CROSS_TAB_ROLE_CHANNEL_NAME,
CROSS_TAB_ROLE_STORAGE_KEY,
CrossTabInvalidationMessage,
CrossTabRoleChangeMessage,
applyRoleChangeToUser,
encodeInvalidationMessage,
encodeRoleChangeMessage,
isCrossTabInvalidationMessage,
isCrossTabRoleChangeMessage,
isStorageInvalidationEvent,
isStorageRoleChangeEvent,
} from '../../frontend/src/app/core/services/auth-session-events.pure';
describe('auth-session-events.pure', () => {
@@ -88,6 +95,105 @@ describe('auth-session-events.pure', () => {
it('exposes a stable channel name and storage key', () => {
expect(CROSS_TAB_CHANNEL_NAME).toBe('hipctf.auth.v1');
expect(CROSS_TAB_STORAGE_KEY).toBe('hipctf.auth.invalidate.v1');
expect(CROSS_TAB_ROLE_CHANNEL_NAME).toBe('hipctf.auth.role.v1');
expect(CROSS_TAB_ROLE_STORAGE_KEY).toBe('hipctf.role.invalidate.v1');
});
});
describe('encodeRoleChangeMessage', () => {
it('produces a role-changed message with userId and newRole', () => {
const before = Date.now();
const msg = encodeRoleChangeMessage('tab-A', 'user-1', 'player');
const after = Date.now();
expect(msg.type).toBe('role-changed');
expect(msg.origin).toBe('tab-A');
expect(msg.userId).toBe('user-1');
expect(msg.newRole).toBe('player');
expect(msg.ts).toBeGreaterThanOrEqual(before);
expect(msg.ts).toBeLessThanOrEqual(after);
});
});
describe('isCrossTabRoleChangeMessage', () => {
it('accepts a properly shaped message', () => {
const msg: CrossTabRoleChangeMessage = {
type: 'role-changed',
origin: 'x',
ts: 1,
userId: 'u1',
newRole: 'player',
};
expect(isCrossTabRoleChangeMessage(msg)).toBe(true);
});
it('rejects a session-invalidated message (does not double-fire)', () => {
const msg: CrossTabInvalidationMessage = { type: 'session-invalidated', origin: 'x', ts: 1 };
expect(isCrossTabRoleChangeMessage(msg)).toBe(false);
});
it('rejects objects with wrong/missing fields', () => {
expect(isCrossTabRoleChangeMessage({ type: 'role-changed', origin: 'a', ts: 1, userId: 'u', newRole: 'super' })).toBe(
false,
);
expect(isCrossTabRoleChangeMessage({ type: 'role-changed', origin: 'a', ts: 1, userId: 1, newRole: 'admin' })).toBe(
false,
);
expect(isCrossTabRoleChangeMessage({ type: 'role-changed', origin: 'a', ts: 'now', userId: 'u', newRole: 'admin' })).toBe(
false,
);
});
it('rejects non-objects', () => {
expect(isCrossTabRoleChangeMessage(null)).toBe(false);
expect(isCrossTabRoleChangeMessage(undefined)).toBe(false);
expect(isCrossTabRoleChangeMessage('string')).toBe(false);
});
});
describe('isStorageRoleChangeEvent', () => {
it('accepts a well-formed storage event for the role key', () => {
const ev = {
key: CROSS_TAB_ROLE_STORAGE_KEY,
newValue: JSON.stringify(encodeRoleChangeMessage('tab-X', 'u1', 'player')),
};
expect(isStorageRoleChangeEvent(ev)).toBe(true);
});
it('rejects events with a different key', () => {
const ev = {
key: 'something.else',
newValue: JSON.stringify(encodeRoleChangeMessage('tab-X', 'u1', 'player')),
};
expect(isStorageRoleChangeEvent(ev)).toBe(false);
});
it('rejects malformed JSON payloads', () => {
const ev = { key: CROSS_TAB_ROLE_STORAGE_KEY, newValue: 'not-json' };
expect(isStorageRoleChangeEvent(ev)).toBe(false);
});
it('rejects nullish events', () => {
expect(isStorageRoleChangeEvent(null)).toBe(false);
expect(isStorageRoleChangeEvent(undefined)).toBe(false);
});
});
describe('applyRoleChangeToUser', () => {
it('returns a new user with the updated role when they differ', () => {
const u = { id: 'u1', role: 'admin' as const };
const out = applyRoleChangeToUser(u, 'player');
expect(out).toEqual({ id: 'u1', role: 'player' });
expect(out).not.toBe(u);
});
it('returns the same user object (no-op) when role already matches', () => {
const u = { id: 'u1', role: 'player' as const };
const out = applyRoleChangeToUser(u, 'player');
expect(out).toBe(u);
});
it('returns the input untouched for null users', () => {
expect(applyRoleChangeToUser(null, 'admin')).toBeNull();
});
});
});