From 9c5db01ae58302ad5b53d56a014b674a960b8ba1 Mon Sep 17 00:00:00 2001 From: Oliwier Michalik Date: Tue, 24 Feb 2026 17:50:09 +0100 Subject: [PATCH 1/5] feat: implement tests for core team functionality --- config/session.ts | 2 +- database/factories/team_factory.ts | 51 +++++++++++ database/seeders/2_team_seeder.ts | 33 ++++++-- tests/bootstrap.ts | 14 +++- tests/functional/teams/teams.spec.ts | 121 +++++++++++++++++++++++++++ 5 files changed, 212 insertions(+), 9 deletions(-) create mode 100644 database/factories/team_factory.ts create mode 100644 tests/functional/teams/teams.spec.ts diff --git a/config/session.ts b/config/session.ts index 86d74a8..131be0d 100644 --- a/config/session.ts +++ b/config/session.ts @@ -57,7 +57,7 @@ const sessionConfig = defineConfig({ * variable in order to infer the store name without any * errors. */ - store: env.get('SESSION_DRIVER'), + store: app.inTest ? 'memory' : env.get('SESSION_DRIVER'), /** * List of configured stores. Refer documentation to see diff --git a/database/factories/team_factory.ts b/database/factories/team_factory.ts new file mode 100644 index 0000000..90dcd10 --- /dev/null +++ b/database/factories/team_factory.ts @@ -0,0 +1,51 @@ +/* + * ______ __ __ + * _ __/ ____/___ ____ / /____ _____/ /_ + * | |/_/ / / __ \/ __ \/ __/ _ \/ ___/ __/ + * _> ({ + // eslint-disable-next-line @unicorn/no-await-expression-member + eventId: (await Event.findByOrFail('slug', 'no-tasks')).id, + name: faker.company.name(), + })) + .relation('members', () => TeamMemberFactory) + .after('create', async (_, { members }) => { + if (members.length > 0) + // Set the first member to be an admin + members[0].permissions = TeamMemberGuard.allPermissions() + await members[0].save() + }) + .build() + +export const TeamMemberFactory = factory + .define(TeamMember, async ({ }) => ({ + permissions: TeamMemberGuard.build(), + })) + .relation('user', () => UserFactory) + .build() diff --git a/database/seeders/2_team_seeder.ts b/database/seeders/2_team_seeder.ts index bbb0586..d87de45 100644 --- a/database/seeders/2_team_seeder.ts +++ b/database/seeders/2_team_seeder.ts @@ -1,3 +1,26 @@ +/* + * ______ __ __ + * _ __/ ____/___ ____ / /____ _____/ /_ + * | |/_/ / / __ \/ __ \/ __/ _ \/ ___/ __/ + * _> > = { * Learn more - https://japa.dev/docs/test-suites#lifecycle-hooks */ export const configureSuite: Config['configureSuite'] = (suite) => { - if (['browser', 'functional', 'e2e'].includes(suite.name)) + if (['browser', 'functional', 'e2e'].includes(suite.name)) return suite.setup(() => testUtils.httpServer().start()) - + } diff --git a/tests/functional/teams/teams.spec.ts b/tests/functional/teams/teams.spec.ts new file mode 100644 index 0000000..0468286 --- /dev/null +++ b/tests/functional/teams/teams.spec.ts @@ -0,0 +1,121 @@ +/* + * ______ __ __ + * _ __/ ____/___ ____ / /____ _____/ /_ + * | |/_/ / / __ \/ __ \/ __/ _ \/ ___/ __/ + * _> { + group.each.setup(() => testUtils.db().seed()) + group.each.teardown(() => testUtils.db().truncate()) + + test('Creates a new team successfully', async ({ client }) => { + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.post('/events/hackathon-tasks/teams').json({ + name: 'My Team', + }).loginAs(admin) + + response.assertCreated() + response.assertBodyContains({ + name: 'My Team' + }) + }) + + test('Fails to create a team without permissions', async ({ client }) => { + const user = await User.findByOrFail('nickname', 'user') + + const response = await client.post('/events/not-visible/teams').json({ + name: 'My Team' + }).loginAs(user) + + response.assertForbidden() + }) + + test('Updates team with new name', async ({ client }) => { + const team = await TeamFactory.with('members', 1, (member) => member.with('user')).create() + + const response = await client.put(`/teams/${team.id}`).json({ + name: 'Updated Team Name' + }).loginAs(team.members[0].user) + + response.assertOk() + response.assertBodyContains({ + name: 'Updated Team Name' + }) + }) + + test('Cannot update team with missing permissions', async ({ client }) => { + // Only the first user from the factory has administrative permissions + const team = await TeamFactory.with('members', 2, (member) => member.with('user')).create() + + const response = await client.put(`/teams/${team.id}`).json({ + name: 'Updated Team Name', + }).loginAs(team.members[1].user) + + response.assertForbidden() + }) + + test('Deletes a team successfully', async ({ client }) => { + const team = await TeamFactory.with('members', 1, (member) => member.with('user')).create() + + const response = await client.delete(`/teams/${team.id}`).json({ + confirmation: team.name, + }).loginAs(team.members[0].user) + + response.assertNoContent() + }) + + test('Fails to delete a team with invalid confirmation', async ({ client }) => { + const team = await TeamFactory.with('members', 1, (member) => member.with('user')).create() + + const response = await client.delete(`/teams/${team.id}`).json({ + confirmation: 'Wrong Name', + }).loginAs(team.members[0].user) + + response.assertUnprocessableEntity() + }) + + test('Lists all teams for an event', async ({ client }) => { + const event = await Event.findByOrFail('slug', 'no-tasks') + const teams = await TeamFactory.with('members', 1, (member) => member.with('user')) + .merge({ eventId: event.id }) + .createMany(5) + + const response = await client.get(`/events/${event.id}/teams`) + + response.assertOk() + response.assertBodyContains({ + data: teams.map(team => ({ id: team.id })) + }) + }) + + test('Cannot list teams for draft event', async ({ client }) => { + const event = await Event.findByOrFail('slug', 'not-visible') + + const response = await client.get(`/events/${event.id}/teams`) + response.assertForbidden() + }) +}) From bf0979e3e0e7f70ae931ecbeaadb86b1c8288e64 Mon Sep 17 00:00:00 2001 From: Oliwier Michalik Date: Tue, 24 Feb 2026 19:01:47 +0100 Subject: [PATCH 2/5] feat: implement test suite for invite-related endpoints --- database/factories/team_factory.ts | 9 ++ tests/functional/teams/invites.spec.ts | 189 +++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 tests/functional/teams/invites.spec.ts diff --git a/database/factories/team_factory.ts b/database/factories/team_factory.ts index 90dcd10..d79288c 100644 --- a/database/factories/team_factory.ts +++ b/database/factories/team_factory.ts @@ -27,6 +27,8 @@ import TeamMember from "#models/team/team_member"; import { TeamMemberGuard } from "#utils/permissions"; import { UserFactory } from "#database/factories/user_factory"; import Event from "#models/event/event"; +import TeamInvitation from "#models/team/team_invitation"; +import { DateTime } from "luxon"; export const TeamFactory = factory .define(Team, async ({ faker }) => ({ @@ -49,3 +51,10 @@ export const TeamMemberFactory = factory })) .relation('user', () => UserFactory) .build() + +export const InvitationFactory = factory + .define(TeamInvitation, async ({ faker }) => ({ + token: faker.internet.password({ length: 16, memorable: true }), + expiresAt: DateTime.now().plus({ days: 1 }), + })) + .build() diff --git a/tests/functional/teams/invites.spec.ts b/tests/functional/teams/invites.spec.ts new file mode 100644 index 0000000..56a729d --- /dev/null +++ b/tests/functional/teams/invites.spec.ts @@ -0,0 +1,189 @@ +/* + * ______ __ __ + * _ __/ ____/___ ____ / /____ _____/ /_ + * | |/_/ / / __ \/ __ \/ __/ _ \/ ___/ __/ + * _> { + group.each.setup(async ({ context }) => { + await testUtils.db().seed() + context.event = await Event.findByOrFail('slug', 'hackathon-tasks') + context.team = await TeamFactory.with('members', 2, (member) => member.with('user')) + .merge({ eventId: context.event.id }) + .create() + context.teamAdmin = context.team.members[0].user + }) + group.each.teardown(() => testUtils.db().truncate()) + + test('Can invite other user via code', async ({ client, assert, team }) => { + const response = await client.post(`/teams/${team.id}/invites`).json({ + validFor: '1 hour' + }).loginAs(team.members[0].user) + + response.assertOk() + assert.isUndefined(response.body().inviteeEmail) + assert.exists(response.body().token) + }) + + test('Can invite other user via email', async ({ client, assert, team }) => { + const user = await UserFactory.create() + + const response = await client.post(`/teams/${team.id}/invites`).json({ + email: user.email, + validFor: '1 hour', + }).loginAs(team.members[0].user) + + response.assertOk() + assert.exists(response.body().token) + response.assertBodyContains({ + inviteeEmail: user.email + }) + }) + + test('Cannot invite without permissions', async ({ client, team }) => { + const response = await client.post(`/teams/${team.id}/invites`).json({ + validFor: '1 hour', + }).loginAs(team.members[1].user) + + response.assertForbidden() + }) + + test('Can accept invitation', async ({ client, team, teamAdmin }) => { + const invitation = await InvitationFactory.merge({ inviterId: teamAdmin.id, teamId: team.id }).create() + const user = await UserFactory.create() + + const response = await client.get(`/invitations/${invitation.token}`).loginAs(user) + + response.assertOk() + response.assertBodyContains({ + invitation: { + status: 'ACCEPTED' + }, + member: { + userId: user.id + } + }) + }) + + test('Can reject invitation', async ({ client, assert, team, teamAdmin }) => { + const invitation = await InvitationFactory.merge({ + inviterId: teamAdmin.id, + teamId: team.id, + }).create() + const user = await UserFactory.create() + + const response = await client.get(`/invitations/${invitation.token}?action=REJECT`).loginAs(user) + + response.assertOk() + response.assertBodyContains({ + invitation: { + status: 'DECLINED', + } + }) + assert.isUndefined(response.body().member) + }) + + test("Can accept direct invitation", async ({ client, team, teamAdmin }) => { + const user = await UserFactory.create() + const invitation = await InvitationFactory.merge({ + inviteeEmail: user.email, + inviterId: teamAdmin.id, + teamId: team.id, + }).create() + + const response = await client.get(`/invitations/${invitation.token}`).loginAs(user) + + response.assertOk() + response.assertBodyContains({ + invitation: { + status: 'ACCEPTED', + } + }) + }) + + test('Cannot accept someone else\'s invitation', async ({ client, team, teamAdmin }) => { + const [user1, user2] = await UserFactory.createMany(2) + const invitation = await InvitationFactory.merge({ + inviteeEmail: user1.email, + inviterId: teamAdmin.id, + teamId: team.id, + }).create() + + const response = await client.get(`/invitations/${invitation.token}?action=REJECT`).loginAs(user2) + + response.assertForbidden() + }) + + test('Can list team invitations', async ({ client, team, teamAdmin }) => { + const invitations = await InvitationFactory.merge({ + inviterId: teamAdmin.id, + teamId: team.id + }).createMany(10) + + const response = await client.get(`/teams/${team.id}/invites`).loginAs(teamAdmin) + + response.assertOk() + response.assertBodyContains(invitations.map(invitation => ({ + id: invitation.id + }))) + }) + + test('Cannot list team invitation without permissions', async ({ client, team }) => { + const user = await UserFactory.create() + const response = await client.get(`/teams/${team.id}/invites`).loginAs(user) + + response.assertForbidden() + }) + + test('Can list own invitations', async ({ client, assert, team, teamAdmin }) => { + const user = await UserFactory.create() + await InvitationFactory.merge({ + inviteeEmail: user.email, + inviterId: teamAdmin.id, + teamId: team.id, + }).createMany(5) + await InvitationFactory.merge({ // Create invitations not linked with user + inviterId: teamAdmin.id, + teamId: team.id, + }).createMany(5) + + const response = await client.get('/invitations').loginAs(user) + + response.assertOk() + assert.lengthOf(response.body().data, 5) + }) +}) + From 334c647283a12d9e9e8b9cac3c7743352e15a2f3 Mon Sep 17 00:00:00 2001 From: Oliwier Michalik Date: Tue, 24 Feb 2026 19:09:40 +0100 Subject: [PATCH 3/5] chore: remove unnecessary env keys from .env.example # Conflicts: # .env.example --- .env.example | 3 --- 1 file changed, 3 deletions(-) diff --git a/.env.example b/.env.example index 213c454..52ce3a6 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,3 @@ DISCORD_CLIENT_SECRET=someclientsecret GITHUB_CLIENT_ID=someclientid GITHUB_CLIENT_SECRET=someclientsecret DRIVE_DISK=s3 -AWS_ACCESS_KEY_ID=awskeyid -AWS_SECRET_ACCESS_KEY=awssecretaccesskey -AWS_REGION=your-region \ No newline at end of file From e2b3c138d3f02fad475ff1d95864fb08fb343c94 Mon Sep 17 00:00:00 2001 From: Oliwier Michalik Date: Tue, 24 Feb 2026 20:33:15 +0100 Subject: [PATCH 4/5] feat: implement more team/invite related tests and implement team leaving/kicking feature --- app/controllers/teams_controller.ts | 24 +++++- app/policies/team_policy.ts | 13 ++- app/validators/team.ts | 17 +++- .../1771616944000_create_teams_table.ts | 2 + start/routes.ts | 1 + tests/functional/teams/invites.spec.ts | 82 ++++++++++++------ tests/functional/teams/teams.spec.ts | 85 +++++++++++++++++++ 7 files changed, 188 insertions(+), 36 deletions(-) diff --git a/app/controllers/teams_controller.ts b/app/controllers/teams_controller.ts index b4852ff..a9d9935 100644 --- a/app/controllers/teams_controller.ts +++ b/app/controllers/teams_controller.ts @@ -24,6 +24,7 @@ import type { HttpContext } from '@adonisjs/core/http' import { createTeamValidator, + kickMemberValidator, teamInvitationResponseValidator, teamInvitationValidator, updateTeamValidator, @@ -64,8 +65,10 @@ export default class TeamsController { * Handle form submission for the creation action */ async store({ auth, bouncer, params, request, response }: HttpContext) { - const { accessCode, ...payload } = await request.validateUsing(createTeamValidator) const event = await Event.findByUuidOrSlug(params.event_id) + const { accessCode, ...payload } = await request.validateUsing(createTeamValidator, { + meta: { eventId: event.id }, + }) await bouncer.with(TeamPolicy).authorize('create', event, accessCode) const team = await db.transaction(async (trx) => { @@ -115,7 +118,10 @@ export default class TeamsController { const { params } = await request.validateUsing(paramsIdValidator) const team = await Team.findOrFail(params.id) const payload = await request.validateUsing(updateTeamValidator, { - meta: { teamName: team.name } + meta: { + teamName: team.name, + eventId: team.eventId + } }) await bouncer.with(TeamPolicy).authorize('edit', team) @@ -145,6 +151,20 @@ export default class TeamsController { return response.noContent() } + // Kick team member by member uuid + async kickMember({ auth, bouncer, request, response }: HttpContext) { + const { member, params } = await request.validateUsing(kickMemberValidator) + const team = await Team.findOrFail(params.id) + const userId = member + // eslint-disable-next-line @unicorn/no-await-expression-member + ? (await team.related('members').query().where('id', member).firstOrFail()).userId + : auth.getUserOrFail().id + await bouncer.with(TeamPolicy).authorize('leave', team, userId) + + await team.related('members').query().where('user_id', userId).delete() + return response.noContent() + } + // Create a new invite async storeInvite({ auth, bouncer, request }: HttpContext) { const { params } = await request.validateUsing(paramsIdValidator) diff --git a/app/policies/team_policy.ts b/app/policies/team_policy.ts index acce9d8..182f992 100644 --- a/app/policies/team_policy.ts +++ b/app/policies/team_policy.ts @@ -67,6 +67,13 @@ export default class TeamPolicy extends BasePolicy { return TeamMemberGuard.can(member, 'REGISTER_TASK') } + async leave(user: User, team: Team, targetId: number): Promise { + if (UserGuard.can(user, 'MANAGE_ALL_TEAMS')) + return true + + return targetId === user.id || this.edit(user, team) + } + async invite(user: User, team: Team, otherUserEmail?: string): Promise { if (otherUserEmail) { const foundMembers = await team.related('members') @@ -79,9 +86,9 @@ export default class TeamPolicy extends BasePolicy { return AuthorizationResponse.deny('User is already a member of this team') } - const members = await team.related('members').query().count('id') + const members = await team.related('members').query().count('*', 'count').firstOrFail() const event = await team.related('event').query().firstOrFail() - if (members.length >= event.maxTeamSize) + if (members.$extras.count >= event.maxTeamSize) return AuthorizationResponse.deny('Team already has maximum number of members') return this.edit(user, team) @@ -99,7 +106,7 @@ export default class TeamPolicy extends BasePolicy { if (invitation.expiresAt! < now) { invitation.status = 'EXPIRED' await invitation.save() - return AuthorizationResponse.deny('Invitation is expired') + return AuthorizationResponse.deny('Invitation is expired', 422) } if (invitation.inviteeEmail && invitation.inviteeEmail !== user.email) diff --git a/app/validators/team.ts b/app/validators/team.ts index 149f776..924f12d 100644 --- a/app/validators/team.ts +++ b/app/validators/team.ts @@ -31,8 +31,9 @@ const teamSchema = { export const createTeamValidator = vine.compile( vine.object({ ...teamSchema, - name: vine.string().trim().unique(async (db, value) => { - const match = await db.from('teams').where('name', value).first() + name: vine.string().trim().unique(async (db, value, field) => { + const eventId = field.meta.eventId + const match = await db.from('teams').where('name', value).where('event_id', eventId).first() return !match }), accessCode: vine.string().optional(), @@ -45,12 +46,22 @@ export const updateTeamValidator = vine.compile( name: vine.string().trim().unique(async (db, value, field) => { if (value === field.meta.teamName) return true // Ignore name collision with self - const match = await db.from('teams').where('name', value).first() + const eventId = field.meta.eventId + const match = await db.from('teams').where('name', value).where('event_id', eventId).first() return !match }), }) ) +export const kickMemberValidator = vine.compile( + vine.object({ + member: vine.string().uuid().optional(), + params: vine.object({ + id: vine.string().uuid(), + }), + }) +) + export const teamInvitationValidator = vine.compile( vine.object({ email: vine.string().email().trim().optional(), diff --git a/database/migrations/1771616944000_create_teams_table.ts b/database/migrations/1771616944000_create_teams_table.ts index 654b1bb..a7aebc5 100644 --- a/database/migrations/1771616944000_create_teams_table.ts +++ b/database/migrations/1771616944000_create_teams_table.ts @@ -34,6 +34,8 @@ export default class extends BaseSchema { table.timestamp('created_at').notNullable() table.timestamp('updated_at').nullable() + + table.unique(['name', 'event_id']) // Prevent same named teams in a single event }) } diff --git a/start/routes.ts b/start/routes.ts index 3d0b8d8..b835dde 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -93,6 +93,7 @@ router.group(() => { router.group(() => { router.post('/invites', [TeamsController, 'storeInvite']) router.get('/invites', [TeamsController, 'indexInvites']) + router.post('/kick', [TeamsController, 'kickMember']) }).prefix('/teams/:id').use(middleware.auth()) router.group(() => { router.get('/:id', [TeamsController, 'respondToInvite']) diff --git a/tests/functional/teams/invites.spec.ts b/tests/functional/teams/invites.spec.ts index 56a729d..b4973f4 100644 --- a/tests/functional/teams/invites.spec.ts +++ b/tests/functional/teams/invites.spec.ts @@ -23,11 +23,12 @@ import { test } from '@japa/runner' import testUtils from "@adonisjs/core/services/test_utils"; -import { InvitationFactory, TeamFactory } from "#database/factories/team_factory"; +import { InvitationFactory, TeamFactory, TeamMemberFactory } from '#database/factories/team_factory' import Event from "#models/event/event"; import { UserFactory } from "#database/factories/user_factory"; import Team from "#models/team/team"; import User from "#models/user"; +import { DateTime } from "luxon"; declare module '@japa/runner/core' { interface TestContext { @@ -81,40 +82,65 @@ test.group('Invitations functionality', (group) => { response.assertForbidden() }) - test('Can accept invitation', async ({ client, team, teamAdmin }) => { - const invitation = await InvitationFactory.merge({ inviterId: teamAdmin.id, teamId: team.id }).create() - const user = await UserFactory.create() + test('Cannot invite user who is already a team member', async ({ client, team, teamAdmin }) => { + const response = await client.post(`/teams/${team.id}/invites`).json({ + email: team.members[1].user.email, + validFor: '1 hour', + }).loginAs(teamAdmin) - const response = await client.get(`/invitations/${invitation.token}`).loginAs(user) + response.assertForbidden() + }) - response.assertOk() - response.assertBodyContains({ - invitation: { - status: 'ACCEPTED' - }, - member: { - userId: user.id - } - }) + test('Cannot invite if team is at event\'s max member count', async ({ client, event, team, teamAdmin }) => { + await TeamMemberFactory.with('user').merge({ + teamId: team.id + }).createMany(event.maxTeamSize - team.members.length) + + const response = await client.post(`/teams/${team.id}/invites`).json({ + validFor: '1 hour', + }).loginAs(teamAdmin) + + response.assertForbidden() }) - test('Can reject invitation', async ({ client, assert, team, teamAdmin }) => { - const invitation = await InvitationFactory.merge({ - inviterId: teamAdmin.id, - teamId: team.id, - }).create() - const user = await UserFactory.create() + test('Cannot interact with expired invitation') + .with(['ACCEPT', 'REJECT']) + .run(async ({ client, team, teamAdmin }, action) => { + const invitation = await InvitationFactory.merge({ + inviterId: teamAdmin.id, + teamId: team.id, + expiresAt: DateTime.now().minus({ hours: 2 }) + }).create() + const user = await UserFactory.create() - const response = await client.get(`/invitations/${invitation.token}?action=REJECT`).loginAs(user) + const response = await client.get(`/invitations/${invitation.token}?action=${action}`).loginAs(user) - response.assertOk() - response.assertBodyContains({ - invitation: { - status: 'DECLINED', - } + response.assertUnprocessableEntity() + }) + + test('Can interact with invitation') + .with([ + { action: 'ACCEPT', status: 'ACCEPTED' }, + { action: 'REJECT', status: 'DECLINED' }, + ]) + .run(async ({ client, assert, team, teamAdmin }, data) => { + const invitation = await InvitationFactory.merge({ + inviterId: teamAdmin.id, + teamId: team.id, + }).create() + const user = await UserFactory.create() + + const response = await client.get(`/invitations/${invitation.token}?action=${data.action}`).loginAs(user) + + response.assertOk() + response.assertBodyContains({ + invitation: { + status: data.status, + } + }) + if (data.status === 'ACCEPTED') + assert.exists(response.body().member) }) - assert.isUndefined(response.body().member) - }) test("Can accept direct invitation", async ({ client, team, teamAdmin }) => { const user = await UserFactory.create() diff --git a/tests/functional/teams/teams.spec.ts b/tests/functional/teams/teams.spec.ts index 0468286..e57288a 100644 --- a/tests/functional/teams/teams.spec.ts +++ b/tests/functional/teams/teams.spec.ts @@ -118,4 +118,89 @@ test.group('Teams core functionality', (group) => { const response = await client.get(`/events/${event.id}/teams`) response.assertForbidden() }) + + test('Cannot create teams with duplicate names in one event', async ({ client }) => { + const event = await Event.findByOrFail('slug', 'no-tasks') + const team = await TeamFactory.with('members', 1, (member) => member.with('user')) + .merge({ eventId: event.id }) + .create() + + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.post('/events/no-tasks/teams').json({ + name: team.name, + }).loginAs(admin) + + response.assertUnprocessableEntity() + }) + + test('Can create teams with duplicate names in different events', async ({ client }) => { + const event = await Event.findByOrFail('slug', 'no-tasks') + const team = await TeamFactory.with('members', 1, (member) => member.with('user')) + .merge({ eventId: event.id }) + .create() + + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.post('/events/hackathon-tasks/teams').json({ + name: team.name, + }).loginAs(admin) + + response.assertCreated() + }) + + test('Cannot update team to have name colliding with other team', async ({ client }) => { + const event = await Event.findByOrFail('slug', 'no-tasks') + const teams = await TeamFactory.with('members', 1, (member) => member.with('user')) + .merge({ eventId: event.id }) + .createMany(2) + + const admin = await User.findByOrFail('nickname', 'admin') + + const response = await client.put(`/teams/${teams[1].name}`).json({ + name: teams[0].name, + }).loginAs(admin) + + response.assertUnprocessableEntity() + }) + + test('User can leave team', async({ client }) => { + const event = await Event.findByOrFail('slug', 'no-tasks') + const team = await TeamFactory.with('members', 2, (member) => member.with('user')) + .merge({ eventId: event.id }) + .create() + + // Kicking self = leaving + const response = await client.post(`/teams/${team.id}/kick`).json({ + member: team.members[1].id + }).loginAs(team.members[1].user) + + response.assertNoContent() + }) + + test('Team admin can kick people out of team', async ({ client }) => { + const event = await Event.findByOrFail('slug', 'no-tasks') + const team = await TeamFactory.with('members', 2, (member) => member.with('user')) + .merge({ eventId: event.id }) + .create() + + const response = await client.post(`/teams/${team.id}/kick`).json({ + member: team.members[1].id + }).loginAs(team.members[0].user) + + response.assertNoContent() + }) + + test('Unauthorized user cannot kick other people out of team', async ({ client }) => { + const event = await Event.findByOrFail('slug', 'no-tasks') + const team = await TeamFactory.with('members', 3, (member) => member.with('user')) + .merge({ eventId: event.id }) + .create() + + const response = await client.post(`/teams/${team.id}/kick`).json({ + member: team.members[1].id + }).loginAs(team.members[2].user) + + response.assertForbidden() + }) }) From 2cdb5d2091efa0cf3b817eb3c2cf0d8a3ed46981 Mon Sep 17 00:00:00 2001 From: Oliwier Michalik Date: Tue, 24 Feb 2026 21:05:35 +0100 Subject: [PATCH 5/5] feat: disallow owner to leave the team --- app/controllers/teams_controller.ts | 10 +++------ app/policies/team_policy.ts | 11 ++++++++-- app/utils/permissions.ts | 33 ++++++++++++++-------------- app/validators/team.ts | 2 +- tests/functional/teams/teams.spec.ts | 13 +++++++++++ 5 files changed, 43 insertions(+), 26 deletions(-) diff --git a/app/controllers/teams_controller.ts b/app/controllers/teams_controller.ts index a9d9935..372b9ce 100644 --- a/app/controllers/teams_controller.ts +++ b/app/controllers/teams_controller.ts @@ -152,16 +152,12 @@ export default class TeamsController { } // Kick team member by member uuid - async kickMember({ auth, bouncer, request, response }: HttpContext) { + async kickMember({ bouncer, request, response }: HttpContext) { const { member, params } = await request.validateUsing(kickMemberValidator) const team = await Team.findOrFail(params.id) - const userId = member - // eslint-disable-next-line @unicorn/no-await-expression-member - ? (await team.related('members').query().where('id', member).firstOrFail()).userId - : auth.getUserOrFail().id - await bouncer.with(TeamPolicy).authorize('leave', team, userId) + await bouncer.with(TeamPolicy).authorize('kick', team, member) - await team.related('members').query().where('user_id', userId).delete() + await team.related('members').query().where('id', member).delete() return response.noContent() } diff --git a/app/policies/team_policy.ts b/app/policies/team_policy.ts index 182f992..dcffde7 100644 --- a/app/policies/team_policy.ts +++ b/app/policies/team_policy.ts @@ -29,6 +29,7 @@ import { TeamMemberGuard, UserGuard } from '#utils/permissions' import Team from "#models/team/team"; import TeamInvitation from "#models/team/team_invitation"; import { DateTime } from "luxon"; +import TeamMember from "#models/team/team_member"; export default class TeamPolicy extends BasePolicy { // Whether a user can create a new team for an event @@ -67,11 +68,17 @@ export default class TeamPolicy extends BasePolicy { return TeamMemberGuard.can(member, 'REGISTER_TASK') } - async leave(user: User, team: Team, targetId: number): Promise { + async kick(user: User, team: Team, targetMemberId: string): Promise { + const member = await TeamMember.findOrFail(targetMemberId) + if (TeamMemberGuard.can(member, 'IS_OWNER')) + return AuthorizationResponse.deny( + 'Owner cannot be kicked or leave. Please delete the team or transfer ownership to another user.' + ) + if (UserGuard.can(user, 'MANAGE_ALL_TEAMS')) return true - return targetId === user.id || this.edit(user, team) + return member.userId === user.id || this.edit(user, team) } async invite(user: User, team: Team, otherUserEmail?: string): Promise { diff --git a/app/utils/permissions.ts b/app/utils/permissions.ts index c192b11..e15b29c 100644 --- a/app/utils/permissions.ts +++ b/app/utils/permissions.ts @@ -51,32 +51,33 @@ const PermissionsGuard = >(maskEnum: E PERMISSIONS BEGIN HERE */ export enum UserPermissions { - CREATE_EVENT = 1 << 0, // Whether user can create a new event - MANAGE_ALL_EVENTS = 1 << 1, // Whether user can manage all events on platform (administrative) + CREATE_EVENT = 1 << 0, // Whether a user can create a new event + MANAGE_ALL_EVENTS = 1 << 1, // Whether a user can manage all events on the platform (administrative) CREATE_TEAM = 1 << 2, // Whether user can create teams for events - MANAGE_ALL_TEAMS = 1 << 3, // Whether user can manage all teams on platform (administrative) - MANAGE_ALL_TASKS = 1 << 4, // Whether user can manage all tasks on platform + MANAGE_ALL_TEAMS = 1 << 3, // Whether a user can manage all teams on the platform (administrative) + MANAGE_ALL_TASKS = 1 << 4, // Whether a user can manage all tasks on the platform } export const UserGuard = PermissionsGuard(UserPermissions) export enum TeamMemberPermissions { - MANAGE_MEMBERS = 1 << 0, // Whether member can invite, remove and change other member permissions - REGISTER_TASK = 1 << 1, // Whether member can register team for tasks + MANAGE_MEMBERS = 1 << 0, // Whether a member can invite, remove and change other member permissions + REGISTER_TASK = 1 << 1, // Whether a member can register the team for tasks SUBMIT_TASK = 1 << 2, // Whether member can submit tasks - MANAGE_TEAM = 1 << 3, // Whether user can edit and/or delete team + MANAGE_TEAM = 1 << 3, // Whether a user can edit and/or delete the team + IS_OWNER = 1 << 30, // Whether a user is a team owner (leftmost bit for signed 32-bit integer) } export const TeamMemberGuard = PermissionsGuard(TeamMemberPermissions) export enum EventAdminPermissions { - MANAGE_EVENT = 1 << 0, // Whether user can rename event, change description, etc... - CREATE_TASK = 1 << 1, // Whether user can create new tasks for an event - MANAGE_ALL_TASKS = 1 << 2, // Whether user can remove, and edit all tasks - VIEW_DRAFT = 1 << 3, // Whether user can see and edit event in draft state - MANAGE_ADMINS = 1 << 4, // Whether user can assign/revoke other event administrators - MANAGE_ORGANIZATIONS = 1 << 5, // Whether user can manage organizations for the event - MANAGE_SPONSORS = 1 << 6, // Whether user can manage sponsors for the event - MANAGE_JURY_MEMBERS = 1 << 7, // Whether user can manage jury members for the events tasks - EDIT_TASK = 1 << 8, // Whether user can edit tasks (except for those with MANAGE_ALL_TASKS) + MANAGE_EVENT = 1 << 0, // Whether a user can rename an event, change description, etc... + CREATE_TASK = 1 << 1, // Whether a user can create new tasks for an event + MANAGE_ALL_TASKS = 1 << 2, // Whether a user can remove and edit all tasks + VIEW_DRAFT = 1 << 3, // Whether a user can see and edit an event in a draft state + MANAGE_ADMINS = 1 << 4, // Whether a user can assign/revoke other event administrators + MANAGE_ORGANIZATIONS = 1 << 5, // Whether a user can manage organizations for the event + MANAGE_SPONSORS = 1 << 6, // Whether a user can manage sponsors for the event + MANAGE_JURY_MEMBERS = 1 << 7, // Whether a user can manage jury members for the events tasks + EDIT_TASK = 1 << 8, // Whether a user can edit tasks (except for those with MANAGE_ALL_TASKS) } export const EventAdminGuard = PermissionsGuard(EventAdminPermissions) diff --git a/app/validators/team.ts b/app/validators/team.ts index 924f12d..cf001ae 100644 --- a/app/validators/team.ts +++ b/app/validators/team.ts @@ -55,7 +55,7 @@ export const updateTeamValidator = vine.compile( export const kickMemberValidator = vine.compile( vine.object({ - member: vine.string().uuid().optional(), + member: vine.string().uuid(), params: vine.object({ id: vine.string().uuid(), }), diff --git a/tests/functional/teams/teams.spec.ts b/tests/functional/teams/teams.spec.ts index e57288a..a98f9b3 100644 --- a/tests/functional/teams/teams.spec.ts +++ b/tests/functional/teams/teams.spec.ts @@ -203,4 +203,17 @@ test.group('Teams core functionality', (group) => { response.assertForbidden() }) + + test('Owner cannot leave the team or be kicked', async({ client }) => { + const event = await Event.findByOrFail('slug', 'no-tasks') + const team = await TeamFactory.with('members', 2, (member) => member.with('user')) + .merge({ eventId: event.id }) + .create() + + const response = await client.post(`/teams/${team.id}/kick`).json({ + member: team.members[0].id, + }).loginAs(team.members[0].user) + + response.assertForbidden() + }) })