diff --git a/apps/api/e2e/tracker.e2e-spec.ts b/apps/api/e2e/tracker.e2e-spec.ts index 67c73e4..9748a4d 100644 --- a/apps/api/e2e/tracker.e2e-spec.ts +++ b/apps/api/e2e/tracker.e2e-spec.ts @@ -977,7 +977,14 @@ describe('task-stream (e2e)', () => { () => rest(null, 'GET', `/projects/${key}`), (r) => r.status === 404, ); - expect((await rest(null, 'GET', `/issues/${issue}`)).status).toBe(404); + const hidden = await rest(null, 'GET', `/issues/${issue}`); + expect(hidden.status).toBe(404); + // The answer names what was asked, not the private project behind it. + expect(hidden.body['message']).toContain(issue); + expect(hidden.body['message']).not.toContain(projectId); + const project = await rest(null, 'GET', `/projects/${key}`); + expect(project.body['message']).toContain(key); + expect(project.body['message']).not.toContain(projectId); }); it('deletes a project with everything in it (cascade saga) and frees its key', async () => { diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 57b7e90..c92f8e4 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -179,11 +179,7 @@ "description": "Name or email", "schema": { "default": "", - "allOf": [ - { - "$ref": "#/components/schemas/Object" - } - ] + "type": "string" } }, { @@ -194,11 +190,7 @@ "minimum": 1, "maximum": 100, "default": 20, - "allOf": [ - { - "$ref": "#/components/schemas/Object" - } - ] + "type": "number" } } ], @@ -1023,11 +1015,7 @@ "minimum": 1, "maximum": 200, "default": 50, - "allOf": [ - { - "$ref": "#/components/schemas/Object" - } - ] + "type": "number" } } ], @@ -1206,11 +1194,7 @@ "minimum": 7, "maximum": 365, "default": 90, - "allOf": [ - { - "$ref": "#/components/schemas/Object" - } - ] + "type": "number" } } ], @@ -1313,11 +1297,7 @@ "minimum": 1, "maximum": 200, "default": 50, - "allOf": [ - { - "$ref": "#/components/schemas/Object" - } - ] + "type": "number" } }, { @@ -1752,11 +1732,7 @@ "minimum": 1, "maximum": 200, "default": 50, - "allOf": [ - { - "$ref": "#/components/schemas/Object" - } - ] + "type": "number" } }, { @@ -1766,11 +1742,7 @@ "schema": { "minimum": 0, "default": 0, - "allOf": [ - { - "$ref": "#/components/schemas/Object" - } - ] + "type": "number" } }, { @@ -1780,11 +1752,7 @@ "description": "true: tickets assigned to me. false: any ticket of my projects (e.g. ?assignedToMe=false&category=QA)", "schema": { "default": true, - "allOf": [ - { - "$ref": "#/components/schemas/Object" - } - ] + "type": "boolean" } }, { @@ -1900,11 +1868,7 @@ "minimum": 1, "maximum": 200, "default": 50, - "allOf": [ - { - "$ref": "#/components/schemas/Object" - } - ] + "type": "number" } }, { @@ -1914,11 +1878,7 @@ "schema": { "minimum": 0, "default": 0, - "allOf": [ - { - "$ref": "#/components/schemas/Object" - } - ] + "type": "number" } }, { @@ -2981,11 +2941,7 @@ "minimum": 1, "maximum": 200, "default": 50, - "allOf": [ - { - "$ref": "#/components/schemas/Object" - } - ] + "type": "number" } }, { @@ -2995,11 +2951,7 @@ "schema": { "minimum": 0, "default": 0, - "allOf": [ - { - "$ref": "#/components/schemas/Object" - } - ] + "type": "number" } } ], @@ -3201,11 +3153,7 @@ "minimum": 1, "maximum": 200, "default": 50, - "allOf": [ - { - "$ref": "#/components/schemas/Object" - } - ] + "type": "number" } }, { @@ -3215,11 +3163,7 @@ "schema": { "minimum": 0, "default": 0, - "allOf": [ - { - "$ref": "#/components/schemas/Object" - } - ] + "type": "number" } } ], @@ -3511,11 +3455,7 @@ "minimum": 1, "maximum": 50, "default": 10, - "allOf": [ - { - "$ref": "#/components/schemas/Object" - } - ] + "type": "number" } } ], @@ -4149,11 +4089,7 @@ "minimum": 1, "maximum": 200, "default": 50, - "allOf": [ - { - "$ref": "#/components/schemas/Object" - } - ] + "type": "number" } }, { @@ -4163,11 +4099,7 @@ "schema": { "minimum": 0, "default": 0, - "allOf": [ - { - "$ref": "#/components/schemas/Object" - } - ] + "type": "number" } } ], @@ -4730,10 +4662,6 @@ "message" ] }, - "Object": { - "type": "object", - "properties": {} - }, "UserDto": { "type": "object", "properties": { @@ -5168,11 +5096,11 @@ ] }, "requiresReason": { - "type": "object", + "type": "boolean", "default": false }, "archived": { - "type": "object", + "type": "boolean", "default": false } }, @@ -5233,7 +5161,7 @@ ] }, "archived": { - "type": "object", + "type": "boolean", "default": false } }, @@ -5257,7 +5185,7 @@ "maxLength": 40 }, "archived": { - "type": "object", + "type": "boolean", "default": false } }, @@ -6410,7 +6338,7 @@ "type": "object", "properties": { "force": { - "type": "object", + "type": "boolean", "default": false, "description": "Publish even with unfinished tickets" } diff --git a/libs/api/identity/presenters/src/lib/http/users.controller.ts b/libs/api/identity/presenters/src/lib/http/users.controller.ts index 4dbdfa3..814bb4b 100644 --- a/libs/api/identity/presenters/src/lib/http/users.controller.ts +++ b/libs/api/identity/presenters/src/lib/http/users.controller.ts @@ -48,13 +48,17 @@ export class MeDto extends UserDto { } export class UserSearchQueryDto { - @ApiPropertyOptional({ description: 'Name or email', default: '' }) + @ApiPropertyOptional({ + type: String, + description: 'Name or email', + default: '', + }) @IsOptional() @IsString() @MaxLength(100) search = ''; - @ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 }) + @ApiPropertyOptional({ type: Number, default: 20, minimum: 1, maximum: 100 }) @IsOptional() @Type(() => Number) @IsInt() diff --git a/libs/api/tracker/application/src/lib/queries/get-comments.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-comments.query-handler.ts index d01f70c..1e3a158 100644 --- a/libs/api/tracker/application/src/lib/queries/get-comments.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-comments.query-handler.ts @@ -21,7 +21,10 @@ export class GetCommentsQueryHandler 'Issue', query.issueId, ); - await this.access.assertCanView(issue.projectId, query.viewerId); + await this.access.assertCanView(issue.projectId, query.viewerId, { + entity: 'Issue', + ref: query.issueId, + }); return this.findComments.findByIssue( issue.id, Math.min(query.limit, 200), diff --git a/libs/api/tracker/application/src/lib/queries/get-issue.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-issue.query-handler.ts index 24dd16c..6ab4b8e 100644 --- a/libs/api/tracker/application/src/lib/queries/get-issue.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-issue.query-handler.ts @@ -18,7 +18,10 @@ export class GetIssueQueryHandler ? await this.findIssues.findById(query.idOrKey) : await this.findIssues.findByKey(query.idOrKey.toUpperCase()); const found = this.access.assertFound(issue, 'Issue', query.idOrKey); - await this.access.assertCanView(found.projectId, query.viewerId); + await this.access.assertCanView(found.projectId, query.viewerId, { + entity: 'Issue', + ref: query.idOrKey, + }); return found; } } diff --git a/libs/api/tracker/application/src/lib/queries/get-project.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-project.query-handler.ts index c5e954c..18f0791 100644 --- a/libs/api/tracker/application/src/lib/queries/get-project.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-project.query-handler.ts @@ -18,7 +18,10 @@ export class GetProjectQueryHandler ? await this.findProjects.findById(query.idOrKey) : await this.findProjects.findByKey(query.idOrKey.toUpperCase()); const found = this.access.assertFound(project, 'Project', query.idOrKey); - await this.access.assertCanView(found.id, query.viewerId); + await this.access.assertCanView(found.id, query.viewerId, { + entity: 'Project', + ref: query.idOrKey, + }); return found; } } diff --git a/libs/api/tracker/application/src/lib/queries/get-release-burnup.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-release-burnup.query-handler.ts index ea797ca..fc4e426 100644 --- a/libs/api/tracker/application/src/lib/queries/get-release-burnup.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-release-burnup.query-handler.ts @@ -24,7 +24,10 @@ export class GetReleaseBurnupQueryHandler 'Release', query.releaseId, ); - await this.access.assertCanView(release.projectId, query.viewerId); + await this.access.assertCanView(release.projectId, query.viewerId, { + entity: 'Release', + ref: query.releaseId, + }); const [issues, timeline] = await Promise.all([ this.findIssues.findByProject(release.projectId), this.findTimeline.findByProject(release.projectId), diff --git a/libs/api/tracker/application/src/lib/queries/get-release-progress.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-release-progress.query-handler.ts index ece9578..c3b7d8a 100644 --- a/libs/api/tracker/application/src/lib/queries/get-release-progress.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-release-progress.query-handler.ts @@ -23,7 +23,10 @@ export class GetReleaseProgressQueryHandler 'Release', query.releaseId, ); - await this.access.assertCanView(release.projectId, query.viewerId); + await this.access.assertCanView(release.projectId, query.viewerId, { + entity: 'Release', + ref: query.releaseId, + }); return this.findIssues.releaseProgress(release.id); } } diff --git a/libs/api/tracker/application/src/lib/queries/get-release.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-release.query-handler.ts index 0c26cd9..93ecce0 100644 --- a/libs/api/tracker/application/src/lib/queries/get-release.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-release.query-handler.ts @@ -19,7 +19,10 @@ export class GetReleaseQueryHandler 'Release', query.releaseId, ); - await this.access.assertCanView(release.projectId, query.viewerId); + await this.access.assertCanView(release.projectId, query.viewerId, { + entity: 'Release', + ref: query.releaseId, + }); return release; } } diff --git a/libs/api/tracker/application/src/lib/queries/read-access.ts b/libs/api/tracker/application/src/lib/queries/read-access.ts index ca8baf5..6087e4a 100644 --- a/libs/api/tracker/application/src/lib/queries/read-access.ts +++ b/libs/api/tracker/application/src/lib/queries/read-access.ts @@ -12,10 +12,18 @@ import { FindProjectsRepository } from '../ports/find-projects.repository'; export class ReadAccess { constructor(private readonly findProjects: FindProjectsRepository) {} - /** The viewer's role, or null when they read a public project as an outsider. */ + /** + * The viewer's role, or null when they read a public project as an outsider. + * `asked` is what the caller looked for: the error names it, never the + * project behind it (that would confirm a private project exists). + */ async assertCanView( projectId: string, viewerId: string | null, + asked: { entity: string; ref: string } = { + entity: 'Project', + ref: projectId, + }, ): Promise { const role = viewerId ? await this.findProjects.findRole(projectId, viewerId) @@ -23,7 +31,7 @@ export class ReadAccess { if (role) return role; const project = await this.findProjects.findById(projectId); // Same answer for "doesn't exist" and "not yours": don't leak projects. - if (!project?.isPublic) throw new NotFoundError('Project', projectId); + if (!project?.isPublic) throw new NotFoundError(asked.entity, asked.ref); return null; } diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index 6b4df8c..9a7b1a7 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -132,11 +132,11 @@ export class WorkflowStatusBodyDto { @ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) @IsIn(SETTING_COLORS) color!: string; - @ApiPropertyOptional({ default: false }) + @ApiPropertyOptional({ type: Boolean, default: false }) @IsOptional() @IsBoolean() requiresReason = false; - @ApiPropertyOptional({ default: false }) + @ApiPropertyOptional({ type: Boolean, default: false }) @IsOptional() @IsBoolean() archived = false; @@ -164,7 +164,7 @@ export class IssueTypeOptionBodyDto { @ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) @IsIn(SETTING_COLORS) color!: string; - @ApiPropertyOptional({ default: false }) + @ApiPropertyOptional({ type: Boolean, default: false }) @IsOptional() @IsBoolean() archived = false; @@ -177,7 +177,7 @@ export class AreaOptionBodyDto { @MinLength(1) @MaxLength(40) name!: string; - @ApiPropertyOptional({ default: false }) + @ApiPropertyOptional({ type: Boolean, default: false }) @IsOptional() @IsBoolean() archived = false; @@ -218,7 +218,7 @@ export class ProjectSettingsBodyDto { } export class AnalyticsQueryDto { - @ApiPropertyOptional({ default: 90, minimum: 7, maximum: 365 }) + @ApiPropertyOptional({ type: Number, default: 90, minimum: 7, maximum: 365 }) @IsOptional() @Type(() => Number) @IsInt() @@ -249,7 +249,7 @@ export class BoardQueryDto { @Matches(SETTING_ID) area?: string; - @ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 }) + @ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 }) @IsOptional() @Type(() => Number) @IsInt() @@ -264,7 +264,7 @@ export class ActivityQueryDto { @IsString() issueId?: string; - @ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 }) + @ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 }) @IsOptional() @Type(() => Number) @IsInt() @@ -282,7 +282,7 @@ export class ActivityQueryDto { } export class PageQueryDto { - @ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 }) + @ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 }) @IsOptional() @Type(() => Number) @IsInt() @@ -290,7 +290,7 @@ export class PageQueryDto { @Max(200) limit = 50; - @ApiPropertyOptional({ default: 0, minimum: 0 }) + @ApiPropertyOptional({ type: Number, default: 0, minimum: 0 }) @IsOptional() @Type(() => Number) @IsInt() @@ -302,6 +302,7 @@ export class PageQueryDto { export class MyIssuesQueryDto extends PageQueryDto { @ApiPropertyOptional({ + type: Boolean, default: true, description: 'true: tickets assigned to me. false: any ticket of my projects (e.g. ?assignedToMe=false&category=QA)', @@ -333,7 +334,7 @@ export class MyIssuesQueryDto extends PageQueryDto { } export class UpcomingReleasesQueryDto { - @ApiPropertyOptional({ default: 10, minimum: 1, maximum: 50 }) + @ApiPropertyOptional({ type: Number, default: 10, minimum: 1, maximum: 50 }) @IsOptional() @Type(() => Number) @IsInt() @@ -613,6 +614,7 @@ export class UpdateReleaseDto extends PartialType(CreateReleaseDto) {} export class PublishReleaseDto { @ApiPropertyOptional({ + type: Boolean, default: false, description: 'Publish even with unfinished tickets', })