diff --git a/backend/src/endpoints/auth/auth-helper.ts b/backend/src/endpoints/auth/auth-helper.ts index e9e2f21a4..98ba6a3b7 100644 --- a/backend/src/endpoints/auth/auth-helper.ts +++ b/backend/src/endpoints/auth/auth-helper.ts @@ -1,3 +1,4 @@ +import { ActionTriggerEntity } from '@kleinkram/backend-common/entities/action/action-trigger.entity'; import { FileEntity as File } from '@kleinkram/backend-common/entities/file/file.entity'; import { MissionEntity } from '@kleinkram/backend-common/entities/mission/mission.entity'; import { ProjectEntity } from '@kleinkram/backend-common/entities/project/project.entity'; @@ -160,6 +161,38 @@ export const addAccessConstraintsToFileQuery = ( return query; }; +export const addAccessConstraintsToTriggerQuery = ( + query: SelectQueryBuilder, + userUUID: string, +): SelectQueryBuilder => { + const tok = uuidv4().replaceAll('-', ''); + const missionUUIDQuery = missionAccessUUIDQuery(query, userUUID); + const projectUUIDQuery = projectAccessUUIDQuery(query, userUUID); + const userIsAdminSubQuery = getUserIsAdminSubQuery(query, userUUID); + + const accessBracket = new Brackets((qb) => { + qb.where(`mission.uuid IN (${missionUUIDQuery.getQuery()})`); + qb.orWhere(`project.uuid IN (${projectUUIDQuery.getQuery()})`); + qb.orWhere(`trigger.creatorUuid = :creatorUserUUID_${tok}`); + }); + + query.andWhere( + new Brackets((qb) => { + qb.where(`EXISTS (${userIsAdminSubQuery.getQuery()})`); + qb.orWhere(accessBracket); + }), + ); + + query.setParameters({ + ...userIsAdminSubQuery.getParameters(), + ...missionUUIDQuery.getParameters(), + ...projectUUIDQuery.getParameters(), + [`creatorUserUUID_${tok}`]: userUUID, + }); + + return query; +}; + export function addAccessConstraints( // eslint-disable-next-line @typescript-eslint/no-explicit-any qb: SelectQueryBuilder, diff --git a/backend/src/endpoints/auth/guards/action.guards.ts b/backend/src/endpoints/auth/guards/action.guards.ts index 4d5e91609..45eadaf4d 100644 --- a/backend/src/endpoints/auth/guards/action.guards.ts +++ b/backend/src/endpoints/auth/guards/action.guards.ts @@ -7,7 +7,9 @@ import { AccessGroupRights, ActionState, UserRole } from '@kleinkram/shared'; import { BadRequestException, ExecutionContext, + ForbiddenException, Injectable, + NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -51,6 +53,63 @@ export class CanModifyTriggerGuard extends BaseGuard { } } +@Injectable() +export class CanReadTriggerGuard extends BaseGuard { + constructor( + @InjectRepository(ActionTriggerEntity) + private actionTriggerRepository: Repository, + private missionGuardService: MissionGuardService, + ) { + super(); + } + + async canActivate(context: ExecutionContext): Promise { + const { user, apiKey, request } = await this.getUser(context); + + const params = request.params as { uuid?: string } | undefined; + const triggerUUID = params?.uuid; + + if (!triggerUUID) { + return false; + } + + const trigger = await this.actionTriggerRepository.findOne({ + where: { uuid: triggerUUID }, + select: ['uuid', 'creatorUuid', 'missionUuid'], + }); + + if (!trigger) { + throw new NotFoundException('Trigger not found'); + } + + if (trigger.creatorUuid === user.uuid) { + return true; + } + + if (user.role === UserRole.ADMIN) { + return true; + } + + if (apiKey) { + return this.missionGuardService.canKeyAccessMission( + apiKey, + trigger.missionUuid, + AccessGroupRights.READ, + ); + } + + const hasAccess = await this.missionGuardService.canAccessMission( + user, + trigger.missionUuid, + AccessGroupRights.READ, + ); + if (!hasAccess) { + throw new ForbiddenException('Forbidden resource'); + } + return true; + } +} + @Injectable() export class ReadActionGuard extends BaseGuard { constructor(private actionGuardService: ActionGuardService) { diff --git a/backend/src/endpoints/auth/guards/index.ts b/backend/src/endpoints/auth/guards/index.ts index 5db50b8b9..2d4f03507 100644 --- a/backend/src/endpoints/auth/guards/index.ts +++ b/backend/src/endpoints/auth/guards/index.ts @@ -24,6 +24,7 @@ export { FileAccessGuard, MoveFilesGuard } from './file.guards'; // Action guards export { CanModifyTriggerGuard, + CanReadTriggerGuard, CancelActionGuard, CreateActionGuard, CreateActionsGuard, diff --git a/backend/src/endpoints/auth/roles.decorator.ts b/backend/src/endpoints/auth/roles.decorator.ts index d9a1727c9..ca1e10899 100644 --- a/backend/src/endpoints/auth/roles.decorator.ts +++ b/backend/src/endpoints/auth/roles.decorator.ts @@ -13,6 +13,7 @@ import { CanEditGroupByGroupUuid, CanModifyTriggerGuard, CanReadManyMissionsGuard, + CanReadTriggerGuard, CreateActionGuard, CreateActionsGuard, CreateGuard, @@ -379,3 +380,15 @@ export function CanModifyTrigger() { }), ); } + +export function CanReadTrigger() { + return applyDecorators( + SetMetadata('CanReadTrigger', true), + UseGuards(CanReadTriggerGuard), + ApiResponse({ + status: 403, + type: ForbiddenException, + description: 'User does not have Read permissions on this trigger.', + }), + ); +} diff --git a/backend/src/endpoints/trigger/trigger.controller.ts b/backend/src/endpoints/trigger/trigger.controller.ts index 719a2337f..46e9dc026 100644 --- a/backend/src/endpoints/trigger/trigger.controller.ts +++ b/backend/src/endpoints/trigger/trigger.controller.ts @@ -20,6 +20,7 @@ import { AddUser, AuthHeader } from '../auth/parameter-decorator'; import { CanCreateInMissionByBody, CanModifyTrigger, + CanReadTrigger, LoggedIn, } from '../auth/roles.decorator'; @@ -32,9 +33,26 @@ export class TriggerController { @LoggedIn() @ApiOkResponse({ type: ActionTriggerDto, isArray: true }) async findAll( + @AddUser() auth: AuthHeader, @Query('missionUuid') missionUuid?: string, ): Promise { - return this.triggerService.findAll(missionUuid); + const scopedMissionUuid = auth.apiKey + ? auth.apiKey.mission.uuid + : missionUuid; + return this.triggerService.findAll( + auth.user, + scopedMissionUuid, + auth.apiKey, + ); + } + + @Get(':uuid') + @CanReadTrigger() + @ApiOkResponse({ type: ActionTriggerDto }) + async findOne( + @ParameterUuid('uuid') uuid: string, + ): Promise { + return this.triggerService.findOne(uuid); } @Post() diff --git a/backend/src/services/trigger.service.ts b/backend/src/services/trigger.service.ts index 271286801..6703de1ed 100644 --- a/backend/src/services/trigger.service.ts +++ b/backend/src/services/trigger.service.ts @@ -1,3 +1,5 @@ +import { addAccessConstraintsToTriggerQuery } from '@/endpoints/auth/auth-helper'; +import { MissionGuardService } from '@/endpoints/auth/mission-guard.service'; import { ActionTriggerDto, CreateActionTriggerDto, @@ -6,19 +8,23 @@ import { import { ActionTemplateEntity, ActionTriggerEntity, + ApiKeyEntity, MissionEntity, UserEntity, } from '@kleinkram/backend-common'; import { redis } from '@kleinkram/backend-common/consts'; import { ActionDispatcherService } from '@kleinkram/backend-common/modules/action-dispatcher/action-dispatcher.service'; import { + AccessGroupRights, ActionTriggerSource, isValidCron, TriggerEvent, TriggerType, + UserRole, } from '@kleinkram/shared'; import { BadRequestException, + ForbiddenException, Injectable, NotFoundException, OnModuleInit, @@ -43,25 +49,71 @@ export class TriggerService implements OnModuleInit { @InjectRepository(MissionEntity) private missionRepository: Repository, private readonly actionDispatcher: ActionDispatcherService, + private readonly missionGuardService: MissionGuardService, ) {} onModuleInit(): void { this.triggerQueue = new Queue('trigger-queue', { redis }); } - async findAll(missionUuid?: string): Promise { + async findAll( + user: UserEntity, + missionUuid?: string, + apiKey?: ApiKeyEntity, + ): Promise { + if (missionUuid && user.role !== UserRole.ADMIN) { + const hasAccess = apiKey + ? this.missionGuardService.canKeyAccessMission( + apiKey, + missionUuid, + AccessGroupRights.READ, + ) + : await this.missionGuardService.canAccessMission( + user, + missionUuid, + AccessGroupRights.READ, + ); + if (!hasAccess) { + throw new ForbiddenException('Forbidden resource'); + } + } + const query = this.triggerRepository .createQueryBuilder('trigger') .leftJoinAndSelect('trigger.template', 'template') - .leftJoinAndSelect('trigger.creator', 'creator'); + .leftJoinAndSelect('trigger.creator', 'creator') + .leftJoin('trigger.mission', 'mission') + .leftJoin('mission.project', 'project'); if (missionUuid) { - query.where('trigger.missionUuid = :missionUuid', { missionUuid }); + query.andWhere('trigger.missionUuid = :missionUuid', { + missionUuid, + }); } + + // API keys are scoped to their mission (verified above); skip user-level + // access constraints for them. Session users get SQL-level filtering. + if (user.role !== UserRole.ADMIN && !apiKey) { + addAccessConstraintsToTriggerQuery(query, user.uuid); + } + const entities = await query.getMany(); return entities.map((entity) => this.toDto(entity)); } + async findOne(uuid: string): Promise { + const trigger = await this.triggerRepository.findOne({ + where: { uuid }, + relations: { template: true, creator: true }, + }); + + if (!trigger) { + throw new NotFoundException('Trigger not found'); + } + + return this.toDto(trigger); + } + async create( dto: CreateActionTriggerDto, creator: UserEntity, diff --git a/backend/tests/triggers/trigger-ownership.test.ts b/backend/tests/triggers/trigger-ownership.test.ts index 4ea22aa5b..57bd55d3c 100644 --- a/backend/tests/triggers/trigger-ownership.test.ts +++ b/backend/tests/triggers/trigger-ownership.test.ts @@ -1,5 +1,14 @@ +import { appVersion } from '@/app-version'; import { UserEntity } from '@kleinkram/backend-common'; -import { AccessGroupRights, TriggerType } from '@kleinkram/shared'; +import { ApiKeyEntity } from '@kleinkram/backend-common/entities/auth/api-key.entity'; +import { ProjectAccessEntity } from '@kleinkram/backend-common/entities/auth/project-access.entity'; +import { ProjectEntity } from '@kleinkram/backend-common/entities/project/project.entity'; +import { + AccessGroupRights, + AccessGroupType, + KeyTypes, + TriggerType, +} from '@kleinkram/shared'; import { DEFAULT_URL, generateAndFetchDatabaseUser } from '../auth/utilities'; import { createActionUsingPost, @@ -7,6 +16,7 @@ import { createProjectUsingPost, HeaderCreator, } from '../utils/api-calls'; +import { database } from '../utils/database-utilities'; import { setupDatabaseHooks } from '../utils/test-helpers'; describe('Trigger Ownership API Tests', () => { @@ -80,6 +90,50 @@ describe('Trigger Ownership API Tests', () => { return (await response.json()) as { uuid: string }; } + async function grantProjectReadAccess( + user: UserEntity, + projUuid: string, + ): Promise { + const userWithGroups = await database + .getRepository(UserEntity) + .findOneOrFail({ + where: { uuid: user.uuid }, + relations: ['memberships', 'memberships.accessGroup'], + }); + const primaryGroup = userWithGroups.memberships?.find( + (m) => m.accessGroup?.type === AccessGroupType.PRIMARY, + )?.accessGroup; + if (!primaryGroup) { + throw new Error('User has no primary access group'); + } + + const projectAccess = database + .getRepository(ProjectAccessEntity) + .create({ + project: { uuid: projUuid } as ProjectEntity, + accessGroup: primaryGroup, + rights: AccessGroupRights.READ, + }); + await database.getRepository(ProjectAccessEntity).save(projectAccess); + } + + async function createMissionApiKey( + user: UserEntity, + missionUUID: string, + rights: AccessGroupRights, + ): Promise { + const apiKeyRepo = database.getRepository(ApiKeyEntity); + const apiKeyEntity = apiKeyRepo.create({ + // eslint-disable-next-line @typescript-eslint/naming-convention + key_type: KeyTypes.ACTION, + mission: { uuid: missionUUID }, + rights, + user, + }); + await apiKeyRepo.save(apiKeyEntity); + return apiKeyEntity.apikey; + } + test('User B should NOT be able to update User A trigger', async () => { // 1. User A creates a trigger const trigger = await createTrigger(userA, { @@ -179,4 +233,331 @@ describe('Trigger Ownership API Tests', () => { expect(response.status).toBe(200); }); + + test('User B should NOT be able to read User A trigger', async () => { + const trigger = await createTrigger(userA, { + name: 'User A Trigger', + description: 'Owned by User A', + type: TriggerType.WEBHOOK, + missionUuid: missionUuid, + templateUuid: templateUuid, + config: {}, + }); + + const headersBuilder = new HeaderCreator(userB); + const response = await fetch( + `${DEFAULT_URL}/triggers/${trigger.uuid}`, + { + method: 'GET', + headers: headersBuilder.getHeaders(), + }, + ); + + expect(response.status).toBe(403); + }); + + test('Should return 404 for non-existent trigger', async () => { + const headersBuilder = new HeaderCreator(userA); + const nonExistentUuid = '00000000-0000-0000-0000-000000000000'; + const response = await fetch( + `${DEFAULT_URL}/triggers/${nonExistentUuid}`, + { + method: 'GET', + headers: headersBuilder.getHeaders(), + }, + ); + + expect(response.status).toBe(404); + }); + + test('Non-creator with mission READ access can read trigger', async () => { + const trigger = await createTrigger(userA, { + name: 'User A Trigger', + description: 'Owned by User A', + type: TriggerType.WEBHOOK, + missionUuid: missionUuid, + templateUuid: templateUuid, + config: {}, + }); + + await grantProjectReadAccess(userB, projectUuid); + + const headersBuilder = new HeaderCreator(userB); + const response = await fetch( + `${DEFAULT_URL}/triggers/${trigger.uuid}`, + { + method: 'GET', + headers: headersBuilder.getHeaders(), + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as { uuid: string }; + expect(body.uuid).toBe(trigger.uuid); + }); + + test('findAll: non-admin without access sees no triggers from inaccessible missions', async () => { + await createTrigger(userA, { + name: 'User A Trigger', + description: 'Owned by User A', + type: TriggerType.WEBHOOK, + missionUuid: missionUuid, + templateUuid: templateUuid, + config: {}, + }); + + const headersBuilder = new HeaderCreator(userB); + const response = await fetch(`${DEFAULT_URL}/triggers`, { + method: 'GET', + headers: headersBuilder.getHeaders(), + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { uuid: string }[]; + expect(body).toHaveLength(0); + }); + + test('findAll: non-admin with READ access sees triggers from accessible mission', async () => { + const trigger = await createTrigger(userA, { + name: 'User A Trigger', + description: 'Owned by User A', + type: TriggerType.WEBHOOK, + missionUuid: missionUuid, + templateUuid: templateUuid, + config: {}, + }); + + await grantProjectReadAccess(userB, projectUuid); + + const headersBuilder = new HeaderCreator(userB); + const response = await fetch(`${DEFAULT_URL}/triggers`, { + method: 'GET', + headers: headersBuilder.getHeaders(), + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { uuid: string }[]; + expect(body).toHaveLength(1); + expect(body[0].uuid).toBe(trigger.uuid); + }); + + test('findAll: non-admin with READ access and missionUuid filter sees only that mission triggers', async () => { + const trigger = await createTrigger(userA, { + name: 'User A Trigger', + description: 'Owned by User A', + type: TriggerType.WEBHOOK, + missionUuid: missionUuid, + templateUuid: templateUuid, + config: {}, + }); + + await grantProjectReadAccess(userB, projectUuid); + + const headersBuilder = new HeaderCreator(userB); + const response = await fetch( + `${DEFAULT_URL}/triggers?missionUuid=${missionUuid}`, + { + method: 'GET', + headers: headersBuilder.getHeaders(), + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as { uuid: string }[]; + expect(body).toHaveLength(1); + expect(body[0].uuid).toBe(trigger.uuid); + }); + + test('findAll: non-admin without access gets 403 for inaccessible missionUuid', async () => { + const headersBuilder = new HeaderCreator(userB); + const response = await fetch( + `${DEFAULT_URL}/triggers?missionUuid=${missionUuid}`, + { + method: 'GET', + headers: headersBuilder.getHeaders(), + }, + ); + + expect(response.status).toBe(403); + }); + + test('findAll: admin sees all triggers', async () => { + await createTrigger(userA, { + name: 'Trigger 1', + description: 'desc', + type: TriggerType.WEBHOOK, + missionUuid: missionUuid, + templateUuid: templateUuid, + config: {}, + }); + await createTrigger(userA, { + name: 'Trigger 2', + description: 'desc', + type: TriggerType.WEBHOOK, + missionUuid: missionUuid, + templateUuid: templateUuid, + config: {}, + }); + + const headersBuilder = new HeaderCreator(userA); + const response = await fetch(`${DEFAULT_URL}/triggers`, { + method: 'GET', + headers: headersBuilder.getHeaders(), + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { uuid: string }[]; + expect(body.length).toBeGreaterThanOrEqual(2); + }); + + test('API key with mission READ access can read trigger', async () => { + const trigger = await createTrigger(userA, { + name: 'User A Trigger', + description: 'Owned by User A', + type: TriggerType.WEBHOOK, + missionUuid: missionUuid, + templateUuid: templateUuid, + config: {}, + }); + + const apiKey = await createMissionApiKey( + userB, + missionUuid, + AccessGroupRights.READ, + ); + + const response = await fetch( + `${DEFAULT_URL}/triggers/${trigger.uuid}`, + { + method: 'GET', + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'x-api-key': apiKey, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'kleinkram-client-version': appVersion, + }, + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as { uuid: string }; + expect(body.uuid).toBe(trigger.uuid); + }); + + test('findAll: API key is scoped to its mission and cannot list triggers from other missions', async () => { + // userA (admin) creates a second mission and a trigger in each mission + const missionUuid2 = await createMissionUsingPost( + { + name: 'test_mission_2', + projectUUID: projectUuid, + tags: {}, + ignoreTags: true, + }, + userA, + ); + + const triggerInScopedMission = await createTrigger(userA, { + name: 'Scoped Mission Trigger', + description: 'desc', + type: TriggerType.WEBHOOK, + missionUuid: missionUuid, + templateUuid: templateUuid, + config: {}, + }); + await createTrigger(userA, { + name: 'Other Mission Trigger', + description: 'desc', + type: TriggerType.WEBHOOK, + missionUuid: missionUuid2, + templateUuid: templateUuid, + config: {}, + }); + + // userB gets an API key scoped to the first mission only + const apiKey = await createMissionApiKey( + userB, + missionUuid, + AccessGroupRights.READ, + ); + + const response = await fetch(`${DEFAULT_URL}/triggers`, { + method: 'GET', + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'x-api-key': apiKey, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'kleinkram-client-version': appVersion, + }, + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { uuid: string }[]; + // API key must only see triggers from its scoped mission, not mission2 + expect(body).toHaveLength(1); + expect(body[0].uuid).toBe(triggerInScopedMission.uuid); + }); + + test('findAll: API key cannot escape its mission scope via missionUuid query param', async () => { + // userA (admin) creates a second mission and a trigger in each mission + const missionUuid2 = await createMissionUsingPost( + { + name: 'test_mission_2', + projectUUID: projectUuid, + tags: {}, + ignoreTags: true, + }, + userA, + ); + + const triggerInScopedMission = await createTrigger(userA, { + name: 'Scoped Mission Trigger', + description: 'desc', + type: TriggerType.WEBHOOK, + missionUuid: missionUuid, + templateUuid: templateUuid, + config: {}, + }); + await createTrigger(userA, { + name: 'Other Mission Trigger', + description: 'desc', + type: TriggerType.WEBHOOK, + missionUuid: missionUuid2, + templateUuid: templateUuid, + config: {}, + }); + + // userB gets an API key scoped to the first mission only + const apiKey = await createMissionApiKey( + userB, + missionUuid, + AccessGroupRights.READ, + ); + + // Adversarial attempt: pass the other mission's UUID as query param, + // trying to escape the API key's mission scope. + const response = await fetch( + `${DEFAULT_URL}/triggers?missionUuid=${missionUuid2}`, + { + method: 'GET', + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'x-api-key': apiKey, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'kleinkram-client-version': appVersion, + }, + }, + ); + + // Either 403 (key has no access to mission2) or 200 with only the + // scoped mission's triggers — both are acceptable security outcomes. + // What must NOT happen: returning triggers from mission2. + expect([200, 403]).toContain(response.status); + if (response.status === 200) { + const body = (await response.json()) as { uuid: string }[]; + const otherMissionTrigger = body.find( + (t) => t.uuid !== triggerInScopedMission.uuid, + ); + expect(otherMissionTrigger).toBeUndefined(); + } + }); }); diff --git a/cli/kleinkram/__init__.py b/cli/kleinkram/__init__.py index 09f4bbce3..3986edda6 100644 --- a/cli/kleinkram/__init__.py +++ b/cli/kleinkram/__init__.py @@ -23,6 +23,7 @@ from kleinkram.wrappers import get_project from kleinkram.wrappers import get_template from kleinkram.wrappers import get_template_revisions +from kleinkram.wrappers import get_trigger from kleinkram.wrappers import launch_execution from kleinkram.wrappers import list_executions from kleinkram.wrappers import list_files @@ -49,6 +50,7 @@ "get_project", "get_template", "get_template_revisions", + "get_trigger", "list_files", "list_missions", "list_projects", diff --git a/cli/kleinkram/api/routes.py b/cli/kleinkram/api/routes.py index 3892e0c43..39003bb95 100644 --- a/cli/kleinkram/api/routes.py +++ b/cli/kleinkram/api/routes.py @@ -281,11 +281,14 @@ def get_triggers(client: AuthenticatedClient, query: Optional[TriggerQuery] = No return list(map(lambda p: _parse_action_trigger(TriggerObject(p)), payload)) +GET_TRIGGER_ENDPOINT = "/triggers/{}" + + def get_trigger( client: AuthenticatedClient, trigger_uuid: UUID, ) -> ActionTrigger: - resp = client.patch(UPDATE_TRIGGER.format(trigger_uuid), json={}) + resp = client.get(GET_TRIGGER_ENDPOINT.format(trigger_uuid)) if resp.status_code == 404: raise kleinkram.errors.TriggerNotFound(f"Trigger not found: {trigger_uuid}") resp.raise_for_status() diff --git a/cli/kleinkram/wrappers.py b/cli/kleinkram/wrappers.py index 55b514aba..64d8a6061 100644 --- a/cli/kleinkram/wrappers.py +++ b/cli/kleinkram/wrappers.py @@ -298,6 +298,18 @@ def list_triggers( return list(kleinkram.api.routes.get_triggers(client, query=query)) +def get_trigger( + trigger_uuid: IdLike, + *, + client: Optional[AuthenticatedClient] = None, +) -> ActionTrigger: + """\ + get detailed information for a specific trigger by its uuid + """ + client = client or AuthenticatedClient() + return kleinkram.api.routes.get_trigger(client, parse_uuid_like(trigger_uuid)) + + @overload def upload( *, diff --git a/cli/tests/test_triggers.py b/cli/tests/test_triggers.py index 5daa4ba85..9f7d60aec 100644 --- a/cli/tests/test_triggers.py +++ b/cli/tests/test_triggers.py @@ -14,6 +14,7 @@ from kleinkram.models import WebhookConfig from kleinkram.wrappers import create_trigger from kleinkram.wrappers import delete_trigger +from kleinkram.wrappers import get_trigger from kleinkram.wrappers import list_triggers from kleinkram.wrappers import update_trigger @@ -49,6 +50,11 @@ def test_trigger_crud_file(empty_mission, action_template): assert trigger.config.patterns == ("*.bag", "data/*.csv") assert trigger.config.event == (FileTriggerEvent.UPLOAD, FileTriggerEvent.DELETE) + # Verify via get_trigger + trigger_detail = get_trigger(trigger_uuid) + assert trigger_detail.uuid == trigger_uuid + assert trigger_detail.name == trigger_name + # 3. Update new_name = f"trig-upd-{token_hex(4)}" new_config = FileConfig(patterns=("*.bin",), event=(FileTriggerEvent.UPLOAD,)) diff --git a/frontend/src/api/services/action.service.ts b/frontend/src/api/services/action.service.ts index 0b4ed9bcf..8a235b06b 100644 --- a/frontend/src/api/services/action.service.ts +++ b/frontend/src/api/services/action.service.ts @@ -170,6 +170,11 @@ export const ActionService = { return data; }, + async getTrigger(uuid: string): Promise { + const { data } = await axios.get(`/triggers/${uuid}`); + return data; + }, + async createTrigger( payload: CreateActionTriggerDto, ): Promise {