Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

import java.util.List;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.pinback.pinback_server.domain.article.application.command.ArticleCreateCommand;
import com.pinback.pinback_server.domain.article.domain.entity.Article;
import com.pinback.pinback_server.domain.article.domain.repository.dto.ArticlesWithUnreadCount;
import com.pinback.pinback_server.domain.article.domain.service.ArticleGetService;
import com.pinback.pinback_server.domain.article.domain.service.ArticleSaveService;
import com.pinback.pinback_server.domain.article.exception.ArticleAlreadyExistException;
Expand Down Expand Up @@ -47,15 +47,36 @@ public ArticleDetailResponse getArticleDetail(long articleId) {
}

public ArticleAllResponse getAllArticles(User user, int pageNumber, int pageSize) {
Page<Article> articles = articleGetService.findAll(user.getId(), PageRequest.of(pageNumber, pageSize));
ArticlesWithUnreadCount projection = articleGetService.findAll(user.getId(),
PageRequest.of(pageNumber, pageSize));

List<ArticlesResponse> articlesResponses = articles.stream()
List<ArticlesResponse> articlesResponses = projection.getArticle().stream()
.map(ArticlesResponse::from)
.toList();

return ArticleAllResponse.of(
articles.getTotalElements(),
projection.getArticle().getTotalElements(),
projection.getUnReadCount(),
articlesResponses
);
}

public ArticleAllResponse getAllArticlesByCategory(User user, long categoryId, int pageNumber, int pageSize) {

Category category = categoryGetService.getCategoryAndUser(categoryId, user);

ArticlesWithUnreadCount projection = articleGetService.findAllByCategory(user.getId(), category,
PageRequest.of(pageNumber, pageSize));

List<ArticlesResponse> articlesResponses = projection.getArticle().stream()
.map(ArticlesResponse::from)
.toList();

return ArticleAllResponse.of(
projection.getArticle().getTotalElements(),
projection.getUnReadCount(),
articlesResponses
);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,12 @@ public static Article create(String url, String memo, User user, Category catego
public boolean isRead() {
return isRead;
}

public void toRead() {
this.isRead = true;
}

public void toUnRead() {
this.isRead = false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

import java.util.UUID;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

import com.pinback.pinback_server.domain.article.domain.entity.Article;
import com.pinback.pinback_server.domain.article.domain.repository.dto.ArticlesWithUnreadCount;

public interface ArticleRepositoryCustom {
Page<Article> findAllCustom(UUID userId, Pageable pageable);
ArticlesWithUnreadCount findAllCustom(UUID userId, Pageable pageable);

ArticlesWithUnreadCount findAllByCategory(UUID userId, long articleId, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
package com.pinback.pinback_server.domain.article.domain.repository;

import static com.pinback.pinback_server.domain.article.domain.entity.QArticle.*;
import static com.pinback.pinback_server.domain.category.domain.entity.QCategory.*;
import static com.pinback.pinback_server.domain.user.domain.entity.QUser.*;

import java.util.List;
import java.util.UUID;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.support.PageableExecutionUtils;
import org.springframework.stereotype.Repository;

import com.pinback.pinback_server.domain.article.domain.entity.Article;
import com.pinback.pinback_server.domain.article.domain.repository.dto.ArticlesWithUnreadCount;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;

Expand All @@ -24,7 +25,7 @@ public class ArticleRepositoryCustomImpl implements ArticleRepositoryCustom {
private final JPAQueryFactory queryFactory;

@Override
public Page<Article> findAllCustom(UUID userId, Pageable pageable) {
public ArticlesWithUnreadCount findAllCustom(UUID userId, Pageable pageable) {
List<Article> articles = queryFactory
.selectFrom(article)
.join(article.user, user).fetchJoin()
Expand All @@ -39,6 +40,40 @@ public Page<Article> findAllCustom(UUID userId, Pageable pageable) {
.from(article)
.where(article.user.id.eq(userId));

return PageableExecutionUtils.getPage(articles, pageable, countQuery::fetchOne);
Long unReadCount = queryFactory
.select(article.count())
.from(article)
.where(article.user.id.eq(userId).and(article.isRead.isFalse()))
.fetchOne();

return new ArticlesWithUnreadCount(unReadCount,
PageableExecutionUtils.getPage(articles, pageable, countQuery::fetchOne));
}

@Override
public ArticlesWithUnreadCount findAllByCategory(UUID userId, long categoryId, Pageable pageable) {

List<Article> articles = queryFactory
.selectFrom(article)
.join(article.category, category).fetchJoin()
.where(article.category.id.eq(categoryId))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(article.createdAt.desc())
.fetch();

JPAQuery<Long> countQuery = queryFactory
.select(article.count())
.from(article)
.where(article.category.id.eq(categoryId));

Long unReadCount = queryFactory
.select(article.count())
.from(article)
.where(article.category.id.eq(categoryId).and(article.isRead.isFalse()))
.fetchOne();

return new ArticlesWithUnreadCount(unReadCount,
PageableExecutionUtils.getPage(articles, pageable, countQuery::fetchOne));
}
Comment on lines +54 to 78
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Critical Security Issue: Missing user authorization in category-based queries

The findAllByCategory method has a serious security flaw - all queries filter only by categoryId but omit the userId filter. This allows users to access articles from other users if they happen to be in the same category.

Apply this fix to add user authorization to all queries:

 	List<Article> articles = queryFactory
 		.selectFrom(article)
 		.join(article.category, category).fetchJoin()
-		.where(article.category.id.eq(categoryId))
+		.where(article.category.id.eq(categoryId).and(article.user.id.eq(userId)))
 		.offset(pageable.getOffset())
 		.limit(pageable.getPageSize())
 		.orderBy(article.createdAt.desc())
 		.fetch();

 	JPAQuery<Long> countQuery = queryFactory
 		.select(article.count())
 		.from(article)
-		.where(article.category.id.eq(categoryId));
+		.where(article.category.id.eq(categoryId).and(article.user.id.eq(userId)));

 	Long unReadCount = queryFactory
 		.select(article.count())
 		.from(article)
-		.where(article.category.id.eq(categoryId).and(article.isRead.isFalse()))
+		.where(article.category.id.eq(categoryId).and(article.user.id.eq(userId)).and(article.isRead.isFalse()))
 		.fetchOne();
🤖 Prompt for AI Agents
In
src/main/java/com/pinback/pinback_server/domain/article/domain/repository/ArticleRepositoryCustomImpl.java
lines 54 to 78, the method findAllByCategory filters articles only by categoryId
without considering userId, causing a security risk by exposing other users'
articles. To fix this, add a condition to all queries to also filter by userId,
ensuring only articles belonging to the specified user and category are
retrieved. Update the where clauses to include article.user.id.eq(userId)
combined with the existing categoryId condition.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.pinback.pinback_server.domain.article.domain.repository.dto;

import org.springframework.data.domain.Page;

import com.pinback.pinback_server.domain.article.domain.entity.Article;
import com.querydsl.core.annotations.QueryProjection;

import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class ArticlesWithUnreadCount {
private Long unReadCount;
private Page<Article> article;

@QueryProjection
public ArticlesWithUnreadCount(Long unReadCount, Page<Article> article) {
this.unReadCount = unReadCount;
this.article = article;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

import java.util.UUID;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.pinback.pinback_server.domain.article.domain.entity.Article;
import com.pinback.pinback_server.domain.article.domain.repository.ArticleRepository;
import com.pinback.pinback_server.domain.article.domain.repository.dto.ArticlesWithUnreadCount;
import com.pinback.pinback_server.domain.article.exception.ArticleNotFoundException;
import com.pinback.pinback_server.domain.category.domain.entity.Category;
import com.pinback.pinback_server.domain.user.domain.entity.User;

import lombok.RequiredArgsConstructor;
Expand All @@ -28,7 +29,11 @@ public Article findById(long articleId) {
return articleRepository.findById(articleId).orElseThrow(ArticleNotFoundException::new);
}

public Page<Article> findAll(UUID userId, PageRequest pageRequest) {
public ArticlesWithUnreadCount findAll(UUID userId, PageRequest pageRequest) {
return articleRepository.findAllCustom(userId, pageRequest);
}

public ArticlesWithUnreadCount findAllByCategory(UUID userId, Category category, PageRequest pageRequest) {
return articleRepository.findAllByCategory(userId, category.getId(), pageRequest);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,14 @@ public ResponseDto<ArticleAllResponse> getAll(@CurrentUser User user, @RequestPa
return ResponseDto.ok(response);
}

@GetMapping("/{categoryId}")
public ResponseDto<ArticleAllResponse> getAllByCategory(@CurrentUser User user, @RequestParam Long categoryId,
@RequestParam int pageNumber,
@RequestParam int pageSize) {

ArticleAllResponse response = articleManagementUsecase.getAllArticlesByCategory(user, categoryId, pageNumber,
pageSize);
return ResponseDto.ok(response);
}
Comment on lines +48 to +56
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix annotation mismatch for categoryId parameter.

The endpoint path uses {categoryId} as a path variable, but the parameter is annotated with @RequestParam. This will cause the endpoint to fail at runtime.

-	public ResponseDto<ArticleAllResponse> getAllByCategory(@CurrentUser User user, @RequestParam Long categoryId,
+	public ResponseDto<ArticleAllResponse> getAllByCategory(@CurrentUser User user, @PathVariable Long categoryId,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@GetMapping("/{categoryId}")
public ResponseDto<ArticleAllResponse> getAllByCategory(@CurrentUser User user, @RequestParam Long categoryId,
@RequestParam int pageNumber,
@RequestParam int pageSize) {
ArticleAllResponse response = articleManagementUsecase.getAllArticlesByCategory(user, categoryId, pageNumber,
pageSize);
return ResponseDto.ok(response);
}
@GetMapping("/{categoryId}")
public ResponseDto<ArticleAllResponse> getAllByCategory(@CurrentUser User user, @PathVariable Long categoryId,
@RequestParam int pageNumber,
@RequestParam int pageSize) {
ArticleAllResponse response = articleManagementUsecase.getAllArticlesByCategory(user, categoryId, pageNumber,
pageSize);
return ResponseDto.ok(response);
}
🤖 Prompt for AI Agents
In
src/main/java/com/pinback/pinback_server/domain/article/presentation/ArticleController.java
around lines 48 to 56, the categoryId parameter is incorrectly annotated with
@RequestParam while the endpoint path uses {categoryId} as a path variable.
Change the annotation on the categoryId parameter from @RequestParam to
@PathVariable to match the path variable in the URL and fix the runtime failure.


}
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@

public record ArticleAllResponse(
long totalArticle,
long totalUnreadArticle,
List<ArticlesResponse> articles
) {
public static ArticleAllResponse of(long totalArticle, List<ArticlesResponse> articles) {
public static ArticleAllResponse of(long totalArticle, long totalUnreadArticle, List<ArticlesResponse> articles) {
return new ArticleAllResponse(
totalArticle,
totalUnreadArticle,
articles
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
package com.pinback.pinback_server.domain.article.presentation.dto.response;

import java.time.LocalDateTime;

import com.pinback.pinback_server.domain.article.domain.entity.Article;

public record ArticlesResponse(
long articleId,
String url,
String memo,
LocalDateTime createdAt,
boolean isRead
) {
public static ArticlesResponse from(Article article) {
return new ArticlesResponse(
article.getId(),
article.getUrl(),
article.getMemo(),
article.getCreatedAt(),
article.isRead()
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ void getAllArticle() {

//then
assertThat(responses.articles()).hasSize(5);
assertThat(responses.articles().get(0).createdAt()).isNotNull();
assertThat(responses.totalArticle()).isEqualTo(12);

}
Expand All @@ -141,4 +142,97 @@ void getAllArticleOrderByCreatedAtDesc() {
.isSortedAccordingTo(Comparator.reverseOrder());
}

@DisplayName("읽지 않은 게시글 수도 알려주어야 한다.")
@Test
void getAllWithUnReadArticles() {
//given
User user = userRepository.save(user());
Category category = categoryRepository.save(category(user));

for (int i = 0; i < 5; i++) {
articleRepository.save(article(user, "article" + i, category));
}

for (int i = 0; i < 3; i++) {
articleRepository.save(readArticle(user, "article2" + i, category));
}

//when
ArticleAllResponse responses = articleManagementUsecase.getAllArticles(user, 0, 8);

//then
assertThat(responses.articles())
.hasSize(8)
.extracting(ArticlesResponse::articleId)
.isSortedAccordingTo(Comparator.reverseOrder());

assertThat(responses.totalUnreadArticle())
.isEqualTo(5);
}

@DisplayName("카테고리 별로 게시글을 조회할 수 있다.")
@Test
void getByCategory() {
//given
User user = userRepository.save(user());
Category category = categoryRepository.save(category(user));
Category category2 = categoryRepository.save(category(user));

for (int i = 0; i < 5; i++) {
articleRepository.save(article(user, "article" + i, category));
}

for (int i = 0; i < 5; i++) {
articleRepository.save(article(user, "article2" + i, category2));
}

//when

ArticleAllResponse responses = articleManagementUsecase.getAllArticlesByCategory(user, category.getId(), 0, 5);

//then

assertThat(responses.articles())
.hasSize(5)
.extracting(ArticlesResponse::articleId)
.isSortedAccordingTo(Comparator.reverseOrder());

assertThat(responses.totalArticle())
.isEqualTo(5);

}

@DisplayName("카테고리 별로 게시글을 조회할 수 있다.")
@Test
void getByCategoryWithUnreadCount() {
//given
User user = userRepository.save(user());
Category category = categoryRepository.save(category(user));
Category category2 = categoryRepository.save(category(user));

for (int i = 0; i < 3; i++) {
articleRepository.save(article(user, "article" + i, category));
}

for (int i = 0; i < 5; i++) {
articleRepository.save(article(user, "article2" + i, category2));
}

for (int i = 0; i < 5; i++) {
articleRepository.save(readArticle(user, "article3" + i, category));
}

//when

ArticleAllResponse responses = articleManagementUsecase.getAllArticlesByCategory(user, category.getId(), 0, 5);

//then

assertThat(responses.totalUnreadArticle()).isEqualTo(3);
assertThat(responses.totalArticle())
.isEqualTo(8);
assertThat(responses.articles().get(0).createdAt()).isNotNull();

}
Comment on lines +205 to +236
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add security test for cross-user access prevention

The current tests don't verify that users cannot access articles from other users in the same category. This is critical given the security issue identified in the repository layer.

Add a test case like this to ensure proper user authorization:

@DisplayName("사용자는 다른 사용자의 아티클을 카테고리로 조회할 수 없다.")
@Test
void getCategoryArticlesWithDifferentUser() {
    //given
    User user1 = userRepository.save(user());
    User user2 = userRepository.save(userWithEmail("user2@test.com"));
    Category category = categoryRepository.save(category(user1));
    
    // User1 creates articles in the category
    for (int i = 0; i < 3; i++) {
        articleRepository.save(article(user1, "user1-article" + i, category));
    }
    
    // User2 should not see User1's articles
    ArticleAllResponse response = articleManagementUsecase.getAllArticlesByCategory(user2, category.getId(), 0, 10);
    
    //then
    assertThat(response.articles()).isEmpty();
    assertThat(response.totalArticle()).isEqualTo(0);
}
🤖 Prompt for AI Agents
In
src/test/java/com/pinback/pinback_server/domain/article/application/ArticleManagementUsecaseTest.java
around lines 205 to 236, add a new test method to verify that a user cannot
access articles belonging to another user within the same category. Create two
users, have one user create articles in a category, then attempt to retrieve
those articles using the second user. Assert that the returned article list is
empty and the total article count is zero to ensure proper authorization and
prevent cross-user data access.


}
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,10 @@ public static Article articleWithCategory(User user, Category category) {
public static Article article(User user, String url, Category category) {
return Article.create(url, "testmemo", user, category, LocalDateTime.of(2025, 7, 7, 12, 0, 0));
}

public static Article readArticle(User user, String url, Category category) {
Article article = Article.create(url, "testmemo", user, category, LocalDateTime.of(2025, 7, 7, 12, 0, 0));
article.toRead();
return article;
}
}