Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ public CommentDTO toDTO(Comment comment) {
);
}

// Todo: Service layer must set visa and author before persisting
public Comment toEntity(CreateCommentDTO dto) {
if (dto == null) return null;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.example.visacasemanagementsystem.comment.service;

import org.example.visacasemanagementsystem.audit.AuditEventType;
import org.example.visacasemanagementsystem.audit.service.AuditService;
import org.example.visacasemanagementsystem.comment.dto.CommentDTO;
import org.example.visacasemanagementsystem.comment.dto.CreateCommentDTO;
import org.example.visacasemanagementsystem.comment.entity.Comment;
Expand All @@ -22,12 +24,14 @@ public class CommentService {
private final CommentMapper commentMapper;
private final UserRepository userRepository;
private final VisaRepository visaRepository;
private final AuditService auditService;

public CommentService(CommentRepository commentRepository, CommentMapper commentMapper , UserRepository userRepository, VisaRepository visaRepository) {
public CommentService(CommentRepository commentRepository, CommentMapper commentMapper , UserRepository userRepository, VisaRepository visaRepository, AuditService auditService) {
this.commentRepository = commentRepository;
this.commentMapper = commentMapper;
this.userRepository = userRepository;
this.visaRepository = visaRepository;
this.auditService = auditService;
}


Expand Down Expand Up @@ -60,6 +64,16 @@ public CommentDTO createComment(CreateCommentDTO dto) {

// Save and return
Comment savedComment = commentRepository.save(comment);

// Create log in database
auditService.createAuditLog(
author.getId (),
visa.getId (),
AuditEventType.UPDATED,
"Comment added by " + author.getFullName()
Comment on lines +69 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Do not build audit attribution from the DTO-supplied author id.

author is resolved from dto.authorId(), so this new audit entry can be forged by posting another user’s id. Since the audit log is now security-sensitive, resolve the actor from the authenticated principal and use that same principal-derived user for both the comment author and createAuditLog.

Suggested direction
-        auditService.createAuditLog(
-                author.getId (),
+        auditService.createAuditLog(
+                authenticatedUser.getId(),
                 visa.getId (),
                 AuditEventType.UPDATED,
-                "Comment added by " + author.getFullName()
+                "Comment added by " + authenticatedUser.getFullName()
 
         );

Based on learnings, once Spring Security is added, the author should be resolved from the authenticated principal rather than from dto.authorId().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/example/visacasemanagementsystem/comment/service/CommentService.java`
around lines 69 - 73, The audit entry is being attributed using the DTO-supplied
author id (author resolved from dto.authorId()), which allows forging; in
CommentService where you call auditService.createAuditLog(...) and build the
Comment author, stop trusting dto.authorId(): resolve the actor/user from the
authenticated principal (e.g., from SecurityContextHolder or your injected
AuthenticationService) once and use that same principal-derived User instance
for both the Comment author and the auditService.createAuditLog(...) call (pass
principal.getId() and principal.getFullName()), and ignore or validate
dto.authorId() against the authenticated user instead of using it directly.


);

return commentMapper.toDTO(savedComment);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll()
)
.csrf(csrf -> csrf.disable())
.headers(headers -> headers.frameOptions(frame -> frame.disable()));
return http.build();
}
Expand Down
144 changes: 111 additions & 33 deletions src/main/resources/templates/visa/details.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
}
.back-link:hover { color: #fff; }

header { border-bottom: 1px solid #222; padding-bottom: 20px; margin-bottom: 30px; }
h1 { font-size: 28px; font-weight: 500; margin: 0; color: #fff; }
header { border-bottom: 1px solid #222; padding-bottom: 20px; margin-bottom: 30px;}
h1 { font-size: 38px; font-weight: 500; margin: 0; color: #fff; }
.user-info { color: #666; font-size: 14px; margin-top: 5px; }

/* Grid för info */
Expand All @@ -39,14 +39,14 @@
border-radius: 8px;
background: #111;
}
.label { font-size: 11px; text-transform: uppercase; color: #666; display: block; margin-bottom: 5px; letter-spacing: 0.5px; }
.value { font-size: 16px; font-weight: 500; color: #ccc; }
.label { font-size: 12px; text-transform: uppercase; color: #666; display: block; margin-bottom: 5px; letter-spacing: 0.5px; }
.value { font-size: 18px; font-weight: 500; color: #ccc; }

/* Admin Actions & Buttons */
.admin-actions {
background: #111;
padding: 20px;
border-radius: 12px;
border-radius: 8px;
border: 1px solid #222;
margin-bottom: 40px;
}
Expand All @@ -55,9 +55,12 @@
color: #fff;
border: 1px solid #333;
padding: 10px 20px;
border-radius: 6px;
border-radius: 8px;
cursor: pointer;
transition: 0.2s;
font-weight: 600;
font-size: 12px;

}
.btn-assign:hover, .btn-approve:hover, button:hover {
background: #fff;
Expand All @@ -70,11 +73,18 @@
background: #181818;
color: #fff;
border: 1px solid #333 !important;
width: 100%;
box-sizing: border-box;
display: block;
border-radius: 8px;
font-size: 12px;
}
#infoReason:focus { outline: none; border-color: #666 !important; }

.action-group{ display: flex; gap: 10px; }

/* Kommentar-sektion */
.comments-section { margin-top: 50px; border-top: 1px solid #222; padding-top: 30px; }
.comments-section { border-top: 1px solid #222; padding-top: 10px; font-size: 20px; font-weight: 600; }
.comment-card { padding: 15px 0; border-bottom: 1px solid #111; }
.comment-meta { font-size: 12px; color: #666; margin-bottom: 5px; }
.comment-text { font-size: 14px; color: #ccc; }
Expand All @@ -86,21 +96,23 @@
padding: 20px;
border-radius: 8px;
border: 1px solid #222;

}
textarea {
width: 100%;
background: #181818;
color: #fff;
border: 1px solid #333;
padding: 10px;
border-radius: 4px;
border-radius: 8px;
resize: vertical;
min-height: 80px;
margin-bottom: 10px;
box-sizing: border-box;
font-size: 12px;
}
textarea:focus { outline: none; border-color: #666; }
.btn-submit { background: #fff; color: #000; font-weight: 600; border: none; }
.btn-submit { background: #fff; color: #000; font-weight: 600; border: none; transition: 0.2s ease; }
.btn-submit:hover { background: #ccc; }
</style>
</head>
Expand All @@ -127,9 +139,10 @@ <h1 th:text="'Case #' + ${visa.id}">Case #101</h1>
<span class="value" th:text="${visa.visaStatus}">SUBMITTED</span>
</div>

<div class="info-item" th:if="${visa.updatedAt != null && visa.updatedAt != visa.createdAt}">
<span class="label">Last Updated</span>
<span class="value" th:text="${#temporals.format(visa.updatedAt, 'yyyy-MM-dd HH:mm')}">2023-10-28 09:30</span>

<div class="info-item">
<span class="label">Nationality</span>
<span class="value" th:text="${visa.nationality}">-</span>
</div>

<div class="info-item">
Expand All @@ -151,9 +164,9 @@ <h1 th:text="'Case #' + ${visa.id}">Case #101</h1>
<span class="value" th:text="${#temporals.format(visa.createdAt, 'yyyy-MM-dd HH:mm')}">2023-10-27</span>
</div>

<div class="info-item">
<span class="label">Nationality</span>
<span class="value" th:text="${visa.nationality}">-</span>
<div class="info-item" th:if="${visa.updatedAt != null && visa.updatedAt != visa.createdAt}">
<span class="label">Last Updated</span>
<span class="value" th:text="${#temporals.format(visa.updatedAt, 'yyyy-MM-dd HH:mm')}">2023-10-28 09:30</span>
</div>

<div class="info-item" style="grid-column: span 2;" th:if="${visa.statusInformation != null}">
Expand All @@ -173,13 +186,13 @@ <h3 style="margin-top:0; color: #ffcc00; font-size: 18px;">Action Required</h3>

<a th:href="@{/visas/{id}/edit(id=${visa.id}, currentUserId=${currentUser.id})}"
class="btn-submit"
style="text-decoration: none; display: inline-block; padding: 12px 24px; width: auto;">
style="text-decoration: none; display: inline-block; padding: 12px 24px; width: auto; border-radius: 8px;">
Edit & Resubmit Application
</a>
</div>

<div th:if="${currentUser.userAuthorization.name() != 'USER' and visa.visaStatus.name() != 'GRANTED' and visa.visaStatus.name() != 'REJECTED'}" class="admin-actions">
<h3 style="margin-top:0; font-size: 16px;">Case Management</h3>
<h3 style=" margin-top:0; font-size: 20px;">Case Management</h3>

<div class="action-group">
<form th:if="${visa.handlerName == 'Unassigned'}"
Expand All @@ -193,16 +206,17 @@ <h3 style="margin-top:0; font-size: 16px;">Case Management</h3>
</div>


<div class="decision-form" style="margin-top: 20px; border-top: 1px solid #eee; padding-top: 20px;">
<div class="decision-form" style="margin-top: 20px; border-top: 1px solid #222; padding-top: 20px;">
<input type="text" id="infoReason"
placeholder="Write reason for rejection or what info is missing..."
style="width: 100%; padding: 10px; margin-bottom: 10px; border: 1px solid #ddd; border-radius: 4px;" required>
placeholder="Write reason for rejection or what information is missing..."
style="width: 100%; padding: 15px; margin-bottom: 20px; border: 1px solid #333; border-radius: 8px; box-sizing: border-box;"
required>

<div style="display: flex; gap: 10px;">
<div style="display: flex; gap: 10px; width: 100%;">
<form th:action="@{/visas/{id}/request-info(id=${visa.id}, currentUserId=${currentUser.id})}" method="post" id="form-info" style="flex: 1;">
<input type="hidden" name="reason" id="hidden-info">
<button type="submit" onclick="document.getElementById('hidden-info').value = document.getElementById('infoReason').value"
style="width: 100%">
style="width: 100%; transition: 0.2s ease;">
Request More Info
</button>
</form>
Expand All @@ -216,34 +230,98 @@ <h3 style="margin-top:0; font-size: 16px;">Case Management</h3>
</form>
</div>
</div>

</div>

<section class="comments-section">
<h3>Comments</h3>

<div th:each="comment : ${comments}" class="comment-card">
<div class="comment-meta">
<strong th:text="${comment.authorName}">Admin</strong> •
<span th:text="${#temporals.format(comment.createdAt, 'MMM dd, HH:mm')}">Oct 27, 14:00</span>
<div id="commentContainer">
<div th:each="comment : ${comments}" class="comment-card">
<div class="comment-meta">
<strong th:text="${comment.authorName}">Admin</strong> •
<span th:text="${#temporals.format(comment.createdAt, 'MMM dd, HH:mm')}">Oct 27, 14:00</span>
</div>
<div class="comment-text" th:text="${comment.text}">This is a comment</div>
</div>
<div class="comment-text" th:text="${comment.text}">This is a comment</div>
</div>

<div th:if="${#lists.isEmpty(comments)}" style="color: #999; padding: 20px 0;">
<div id="noCommentsMessage" th:if="${#lists.isEmpty(comments)}" style="color: #999; padding: 20px 0;">
No comments yet.
</div>

<div class="comment-form">
<form th:action="@{/api/comments}" method="post">
<input type="hidden" name="visaId" th:value="${visa.id}">
<input type="hidden" name="authorId" th:value="${currentUser.id}">
<textarea name="content" placeholder="Write a comment..."></textarea>
<form id="commentForm">
<input type="hidden" name="_csrf" th:value="${_csrf.token}"/>
<input type="hidden" name="_csrf_header" th:value="${_csrf.headerName}"/>

<input type="hidden" id="visaId" th:value="${visa.id}">
<input type="hidden" id="authorId" th:value="${currentUser.id}">

<textarea id="commentText" name="text" placeholder="Write a comment here..." required></textarea>
<button type="submit" class="btn-submit">Send</button>
</form>
</div>
</section>
</div>

<script>
document.getElementById('commentForm').addEventListener('submit', function(e) {
e.preventDefault();

const visaId = document.getElementById('visaId').value;
const authorId = document.getElementById('authorId').value;
const text = document.getElementById('commentText').value;

const token = document.querySelector('input[name="_csrf"]').value
const header = document.querySelector('input[name="_csrf_header"]').value

const payload = {
visaId: parseInt(visaId),
authorId: parseInt(authorId),
text: text
};

fetch('/api/comments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
[header]: token
},
body: JSON.stringify(payload)
})
.then(response => {
if (!response.ok) throw new Error('Could not save comment');
return response.json();
})
.then(comment => {
document.getElementById('commentText').value = '';

const noCommentsMsg = document.getElementById('noCommentsMessage');
if (noCommentsMsg) noCommentsMsg.remove();

const container = document.getElementById('commentContainer');
const card = document.createElement('div');
card.className = 'comment-card';
card.style.animation = 'fadeIn 0.5s';
const meta = document.createElement('div');
meta.className = 'comment-meta';
const author = document.createElement('strong');
author.textContent = comment.authorName;
meta.appendChild(author);
meta.append(' • Just now');

const text = document.createElement('div');
text.className = 'comment-text';
text.textContent = comment.text;
card.appendChild(meta);
card.appendChild(text);
container.prepend(card);
})
.catch(error => {
alert('Error: ' + error.message);
});
});
</script>

</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package org.example.visacasemanagementsystem.comment;

import org.example.visacasemanagementsystem.comment.controller.CommentController;
import org.example.visacasemanagementsystem.comment.dto.CommentDTO;
import org.example.visacasemanagementsystem.comment.dto.CreateCommentDTO;
import org.example.visacasemanagementsystem.comment.service.CommentService;
import org.springframework.http.MediaType;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import tools.jackson.databind.ObjectMapper;

import java.time.LocalDateTime;
import java.util.List;


import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;



@WebMvcTest(CommentController.class)
class CommentControllerTest {

@Autowired
private MockMvc mockMvc;
@MockitoBean
private CommentService commentService;
@Autowired
private ObjectMapper objectMapper;

@Test
@WithMockUser
void createComment_shouldReturnCreated() throws Exception {
// Arrange
CreateCommentDTO createDto= new CreateCommentDTO(1L, 1L, "Test message");
CommentDTO responseDto = new CommentDTO(100L, 1L,"Test User", "Test message", LocalDateTime.now());

when(commentService.createComment(any(CreateCommentDTO.class))).thenReturn(responseDto);

// Act & Assert
mockMvc.perform(post("/api/comments")
.with(csrf())
.contentType((MediaType.APPLICATION_JSON))
.content(objectMapper.writeValueAsString(createDto)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(100L))
.andExpect(jsonPath("$.text").value("Test message"))
.andExpect(jsonPath("$.authorName").value("Test User"));
}

@Test
@WithMockUser
void getCommentsByVisa_ShouldReturnList() throws Exception {
// Arrange
String expectedText = "Hello World";
CommentDTO comment = new CommentDTO(1L, 2L, "Admin", expectedText, LocalDateTime.now());

when(commentService.getCommentsByVisaId(1L)).thenReturn(List.of(comment));

// Act & Assert
mockMvc.perform(get("/api/comments/visa/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].text").value(expectedText));
}
}
Loading