feat(account): add activation and authentication web flow
This commit is contained in:
@@ -65,4 +65,4 @@ BUILD SUCCESS
|
|||||||
|
|
||||||
## External-test boundaries
|
## External-test boundaries
|
||||||
|
|
||||||
The recording SMTP boundary proves the exact in-memory handoff but not Mailpit/network delivery or a browser following the link. MVC activation forms, resend, password reset, session invalidation, lock/deactivation, and production origin/readiness hardening remain separate Iteration 1 or later slices.
|
The recording SMTP boundary proves the exact in-memory handoff but not Mailpit/network delivery. MVC creation, activation, login, role denial, and logout are covered separately by `AccountWebIntegrationTest`; resend, password reset, session invalidation after credential/state changes, lock/deactivation, and production origin/readiness hardening remain separate slices.
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Test Evidence: Account creation, activation, authentication, and logout
|
||||||
|
|
||||||
|
- **Test type:** Web
|
||||||
|
- **Requirement IDs:** `ACC-008–ACC-011, ACC-014, ACC-019, AUTH-001–AUTH-002, SEC-002–SEC-004`
|
||||||
|
- **Scenario IDs:** `AC-ACC-005, AC-ACC-007, AC-AUTH-001`
|
||||||
|
- **Test class/method:** `com.lab.labtimesheet.feature.account.controller.AccountWebIntegrationTest.adminCreatesMentorAndInternThenMentorActivatesAuthenticatesAndLogsOut`
|
||||||
|
- **Implementation commit:** `this milestone commit`
|
||||||
|
|
||||||
|
## Protected behavior
|
||||||
|
|
||||||
|
An authenticated Admin can use the account form to create pending Mentor and Intern accounts, the intended recipient can follow the emailed activation link and set a first password, normalized email login succeeds, a Mentor is denied the Admin account route, and logout clears authentication. Browser-submitted blank Intern fields do not prevent Mentor creation.
|
||||||
|
|
||||||
|
## Test method
|
||||||
|
|
||||||
|
MockMvc drives the production controllers, Thymeleaf templates, CSRF protection, Spring Security login/logout handlers, JPA services, and PostgreSQL 18.4. SMTP is replaced only at the network boundary by an in-memory recording probe. The test extracts the activation token from that immediate test message without logging or persisting the raw value, then exercises the public activation form.
|
||||||
|
|
||||||
|
## Hand-derived expected result
|
||||||
|
|
||||||
|
The Admin form returns 200. Mentor and Intern submissions redirect to `?created` and persist their immutable roles as pending accounts. Activation redirects to `/login?activated`; login with a case/whitespace variant authenticates the normalized Mentor identity. That session receives 403 at the Admin form and becomes unauthenticated after POST `/logout`.
|
||||||
|
|
||||||
|
## RED
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw -Dtest=AccountWebIntegrationTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /admin/accounts/new resolved to ResourceHttpRequestHandler
|
||||||
|
Status expected:<200> but was:<404>
|
||||||
|
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
|
||||||
|
BUILD FAILURE
|
||||||
|
```
|
||||||
|
|
||||||
|
After the MVC boundary first reached GREEN, the test was tightened to submit blank Intern controls exactly as the browser form does and observed a second RED:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /admin/accounts returned accounts/new with
|
||||||
|
"Internship fields are allowed only for Intern accounts"
|
||||||
|
Range for response status value 200 expected:<REDIRECTION> but was:<SUCCESSFUL>
|
||||||
|
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
|
||||||
|
BUILD FAILURE
|
||||||
|
```
|
||||||
|
|
||||||
|
## GREEN
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw -Dtest=AccountWebIntegrationTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Affected suite
|
||||||
|
|
||||||
|
**Command and result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw test
|
||||||
|
|
||||||
|
Tests run: 10, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## External-test boundaries
|
||||||
|
|
||||||
|
This test does not contact Mailpit or an external SMTP server and is not a real browser/accessibility test. It does not cover activation resend, password reset, account lock/deactivation, session invalidation after credential/state changes, production origin configuration, containerization, CI, or deployment.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.lab.labtimesheet.feature.account.controller;
|
||||||
|
|
||||||
|
import java.security.Principal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
|
||||||
|
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
class AccountController {
|
||||||
|
private final AccountService accounts;
|
||||||
|
|
||||||
|
AccountController(AccountService accounts) {
|
||||||
|
this.accounts = accounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/admin/accounts/new")
|
||||||
|
String newAccount() {
|
||||||
|
return "accounts/new";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/admin/accounts")
|
||||||
|
String create(
|
||||||
|
@RequestParam String email,
|
||||||
|
@RequestParam String displayName,
|
||||||
|
@RequestParam GlobalRole role,
|
||||||
|
@RequestParam(required = false) String studentCode,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate internshipStart,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate internshipEnd,
|
||||||
|
Principal principal,
|
||||||
|
Model model) {
|
||||||
|
try {
|
||||||
|
var result = accounts.create(
|
||||||
|
new CreateAccountCommand(
|
||||||
|
email, displayName, role, clean(studentCode), internshipStart, internshipEnd),
|
||||||
|
accounts.requireActiveAdminId(principal.getName()));
|
||||||
|
return result.deliverySucceeded()
|
||||||
|
? "redirect:/admin/accounts/new?created"
|
||||||
|
: "redirect:/admin/accounts/new?deliveryFailed";
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||||
|
model.addAttribute("error", exception.getMessage());
|
||||||
|
return "accounts/new";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String clean(String value) {
|
||||||
|
return value == null || value.isBlank() ? null : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/activate")
|
||||||
|
String activationForm(@RequestParam String token, Model model) {
|
||||||
|
model.addAttribute("token", token);
|
||||||
|
return "accounts/activate";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/activate")
|
||||||
|
String activate(
|
||||||
|
@RequestParam String token,
|
||||||
|
@RequestParam String password,
|
||||||
|
@RequestParam String confirmPassword,
|
||||||
|
Model model) {
|
||||||
|
if (!password.equals(confirmPassword)) {
|
||||||
|
model.addAttribute("token", token);
|
||||||
|
model.addAttribute("error", "Passwords do not match");
|
||||||
|
return "accounts/activate";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (accounts.activate(token, password)) {
|
||||||
|
return "redirect:/login?activated";
|
||||||
|
}
|
||||||
|
model.addAttribute("error", "This activation link is invalid or no longer usable");
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
model.addAttribute("error", exception.getMessage());
|
||||||
|
}
|
||||||
|
model.addAttribute("token", token);
|
||||||
|
return "accounts/activate";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head><meta charset="utf-8"><title>Activate account</title></head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Choose your password</h1>
|
||||||
|
<p th:if="${error}" th:text="${error}" role="alert"></p>
|
||||||
|
<form method="post" th:action="@{/activate}">
|
||||||
|
<input name="token" type="hidden" th:value="${token}">
|
||||||
|
<label>Password <input name="password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label>
|
||||||
|
<label>Confirm password <input name="confirmPassword" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label>
|
||||||
|
<button type="submit">Activate account</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head><meta charset="utf-8"><title>Create account</title></head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Create account</h1>
|
||||||
|
<p th:if="${param.created}" role="status">Account created and activation email sent.</p>
|
||||||
|
<p th:if="${param.deliveryFailed}" role="alert">Account created, but activation delivery failed.</p>
|
||||||
|
<p th:if="${error}" th:text="${error}" role="alert"></p>
|
||||||
|
<form method="post" th:action="@{/admin/accounts}">
|
||||||
|
<label>Email <input name="email" type="email" required autocomplete="off"></label>
|
||||||
|
<label>Display name <input name="displayName" required autocomplete="off"></label>
|
||||||
|
<label>Role
|
||||||
|
<select name="role" required>
|
||||||
|
<option value="ADMIN">Admin</option>
|
||||||
|
<option value="MENTOR">Mentor</option>
|
||||||
|
<option value="INTERN">Intern</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Intern details</legend>
|
||||||
|
<label>Student code <input name="studentCode" autocomplete="off"></label>
|
||||||
|
<label>Internship start <input name="internshipStart" type="date"></label>
|
||||||
|
<label>Internship end <input name="internshipEnd" type="date"></label>
|
||||||
|
</fieldset>
|
||||||
|
<button type="submit">Create account</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||||
<head><meta charset="utf-8"><title>Lab Timesheet</title></head>
|
<head><meta charset="utf-8"><title>Lab Timesheet</title></head>
|
||||||
<body><main><h1>Lab Timesheet</h1></main></body>
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Lab Timesheet</h1>
|
||||||
|
<form method="post" th:action="@{/logout}"><button type="submit">Sign out</button></form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
package com.lab.labtimesheet.feature.account.controller;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||||
|
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
|
||||||
|
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||||
|
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||||
|
import com.lab.labtimesheet.feature.account.service.BootstrapService;
|
||||||
|
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||||
|
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||||
|
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
|
||||||
|
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
|
||||||
|
import com.lab.labtimesheet.feature.integration.service.SmtpProbe;
|
||||||
|
import jakarta.servlet.http.HttpSession;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.test.annotation.DirtiesContext;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
@Import({TestcontainersConfiguration.class, AccountWebIntegrationTest.MailProbeConfiguration.class})
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||||
|
class AccountWebIntegrationTest {
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private BootstrapService bootstrap;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AccountService accounts;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SmtpConfigurationService smtp;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RecordingSmtpProbe mail;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void initializeAdminAndSmtp() {
|
||||||
|
bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
|
||||||
|
long adminId = accounts.requireActiveAdminId("admin@example.com");
|
||||||
|
long draftId = smtp.saveDraft(adminId, new SmtpDraft(
|
||||||
|
"mailpit", 1025, SecurityMode.NONE, null, null, "admin@example.com", "Lab Timesheet"));
|
||||||
|
smtp.testDraft(draftId, adminId, "admin@example.com");
|
||||||
|
smtp.activate(draftId, adminId);
|
||||||
|
mail.messages.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminCreatesMentorAndInternThenMentorActivatesAuthenticatesAndLogsOut() throws Exception {
|
||||||
|
mockMvc.perform(get("/admin/accounts/new").with(user("admin@example.com").roles("ADMIN")))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(view().name("accounts/new"))
|
||||||
|
.andExpect(content().string(org.hamcrest.Matchers.containsString("Internship start")));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/admin/accounts")
|
||||||
|
.with(user("admin@example.com").roles("ADMIN"))
|
||||||
|
.with(csrf())
|
||||||
|
.param("email", " MENTOR@EXAMPLE.COM ")
|
||||||
|
.param("displayName", "Mentor One")
|
||||||
|
.param("role", "MENTOR")
|
||||||
|
.param("studentCode", "")
|
||||||
|
.param("internshipStart", "")
|
||||||
|
.param("internshipEnd", ""))
|
||||||
|
.andExpect(status().is3xxRedirection())
|
||||||
|
.andExpect(redirectedUrl("/admin/accounts/new?created"));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/admin/accounts")
|
||||||
|
.with(user("admin@example.com").roles("ADMIN"))
|
||||||
|
.with(csrf())
|
||||||
|
.param("email", "intern@example.com")
|
||||||
|
.param("displayName", "Intern One")
|
||||||
|
.param("role", "INTERN")
|
||||||
|
.param("studentCode", "STU-001")
|
||||||
|
.param("internshipStart", "2026-08-01")
|
||||||
|
.param("internshipEnd", "2026-12-31"))
|
||||||
|
.andExpect(status().is3xxRedirection())
|
||||||
|
.andExpect(redirectedUrl("/admin/accounts/new?created"));
|
||||||
|
|
||||||
|
var pendingMentor = accounts.requireIdentityByEmail("mentor@example.com");
|
||||||
|
assertThat(pendingMentor.role()).isEqualTo(GlobalRole.MENTOR);
|
||||||
|
assertThat(pendingMentor.status()).isEqualTo(AccountStatus.PENDING_ACTIVATION);
|
||||||
|
assertThat(accounts.requireIdentityByEmail("intern@example.com").role()).isEqualTo(GlobalRole.INTERN);
|
||||||
|
|
||||||
|
String rawToken = mail.activationTokenFor("mentor@example.com");
|
||||||
|
mockMvc.perform(get("/activate").param("token", rawToken))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(view().name("accounts/activate"));
|
||||||
|
mockMvc.perform(post("/activate")
|
||||||
|
.with(csrf())
|
||||||
|
.param("token", rawToken)
|
||||||
|
.param("password", "new secure mentor password")
|
||||||
|
.param("confirmPassword", "new secure mentor password"))
|
||||||
|
.andExpect(status().is3xxRedirection())
|
||||||
|
.andExpect(redirectedUrl("/login?activated"));
|
||||||
|
|
||||||
|
var login = mockMvc.perform(post("/login")
|
||||||
|
.with(csrf())
|
||||||
|
.param("username", " MENTOR@EXAMPLE.COM ")
|
||||||
|
.param("password", "new secure mentor password"))
|
||||||
|
.andExpect(status().is3xxRedirection())
|
||||||
|
.andExpect(authenticated().withUsername("mentor@example.com"))
|
||||||
|
.andReturn();
|
||||||
|
HttpSession session = login.getRequest().getSession(false);
|
||||||
|
assertThat(session).isNotNull();
|
||||||
|
|
||||||
|
mockMvc.perform(get("/admin/accounts/new").session((org.springframework.mock.web.MockHttpSession) session))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
mockMvc.perform(post("/logout")
|
||||||
|
.session((org.springframework.mock.web.MockHttpSession) session)
|
||||||
|
.with(csrf()))
|
||||||
|
.andExpect(status().is3xxRedirection())
|
||||||
|
.andExpect(redirectedUrl("/login?logout"))
|
||||||
|
.andExpect(unauthenticated());
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestConfiguration(proxyBeanMethods = false)
|
||||||
|
static class MailProbeConfiguration {
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
RecordingSmtpProbe recordingSmtpProbe() {
|
||||||
|
return new RecordingSmtpProbe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class RecordingSmtpProbe implements SmtpProbe {
|
||||||
|
private final List<Message> messages = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void send(SmtpConnection connection, String recipient, String subject, String body) {
|
||||||
|
messages.add(new Message(recipient, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
String activationTokenFor(String recipient) {
|
||||||
|
String body = messages.stream()
|
||||||
|
.filter(message -> message.recipient().equals(recipient))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow()
|
||||||
|
.body();
|
||||||
|
int tokenStart = body.indexOf("token=");
|
||||||
|
assertThat(tokenStart).isGreaterThanOrEqualTo(0);
|
||||||
|
return body.substring(tokenStart + "token=".length()).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record Message(String recipient, String body) {
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user