Compare commits

..
3 changed files with 184 additions and 2 deletions
@@ -0,0 +1,82 @@
# Test Evidence: Windows legacy Vietnam timezone startup
- **Test type:** Integration
- **Requirement IDs:** `ARC-001`, `ARC-003`, `GOV-011`, `ATT-002`, `TST-001`, `TST-005`
- **Scenario IDs:** `N/A — user-reported cross-platform startup defect`
- **Test class/method:** `com.lab.labtimesheet.ApplicationTimeZoneIntegrationTest.mainCanonicalizesLegacyAliasBeforeStartingSpring`, `com.lab.labtimesheet.ApplicationTimeZoneIntegrationTest.canonicalizesLegacyVietnamAliasBeforePostgresConnects`, `com.lab.labtimesheet.ApplicationTimeZoneIntegrationTest.leavesSupportedSystemTimeZoneUnchanged`
- **Implementation commit:** `a9fb9487692e84f5a7e7923570cbded58c362a2f`
## Protected behavior
The executable entry point replaces the legacy Windows JVM timezone ID `Asia/Saigon` with the canonical business timezone ID `Asia/Ho_Chi_Minh` before pgJDBC opens a PostgreSQL connection. Other supported operating-system timezone IDs remain unchanged.
## Test method
The entry-point test replaces Spring startup with Mockito's existing static test seam, invokes the real `main` method with a legacy JVM default, and checks that normalization happens before Spring starts. The PostgreSQL test starts a real PostgreSQL 18.4 Testcontainer, proves pgJDBC 42.7.11 is rejected while the JVM default is `Asia/Saigon`, invokes the same startup normalization, and then opens a valid JDBC connection. A negative test verifies that an unrelated supported timezone is not overwritten.
## Hand-derived expected result
PostgreSQL does not accept `Asia/Saigon` as a startup `TimeZone`, while the approved business timezone is `Asia/Ho_Chi_Minh`. Therefore only the legacy alias is replaced, the following connection succeeds, and a supported non-Vietnam timezone remains unchanged.
## RED
**Command**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -Dtest=ApplicationTimeZoneIntegrationTest test
```
**Observed result**
```text
BUILD FAILURE during test compilation.
ApplicationTimeZoneIntegrationTest.java: cannot find symbol normalizeDefaultTimeZone()
```
The failing test established that the application had no pre-Spring normalization boundary.
A second mutation check temporarily removed the new call from `main` and ran:
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin ./mvnw '-Dtest=ApplicationTimeZoneIntegrationTest#mainCanonicalizesLegacyAliasBeforeStartingSpring' test
```
It failed `1/1` with `expected: "Asia/Ho_Chi_Minh" but was: "Asia/Saigon"`, proving the test protects the entry-point ordering rather than only the helper.
## GREEN
**Command**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -Dtest=ApplicationTimeZoneIntegrationTest test
```
**Observed result**
```text
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=ApplicationTimeZoneIntegrationTest,LabtimesheetApplicationTests,PlatformFoundationTest,TimeConfigurationTest,CalendarDevelopmentProfileWebIntegrationTest' test
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test
Tests run: 217, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin ./mvnw -DskipTests -Ddoclint=all javadoc:javadoc
BUILD SUCCESS
```
A local Java 25 process was also started with `-Duser.timezone=Asia/Saigon` against a disposable PostgreSQL 18.4 database on port `55439`. Hikari connected, Flyway migrated the fresh database, Tomcat started on port `18080`, and Spring reported `Started LabtimesheetApplication`. The process shut down cleanly and the disposable database container was removed.
## External-test boundaries
The regression executes the installed pgJDBC version against PostgreSQL 18.4 and reproduces the exact rejected timezone value from the Windows report. It does not run the Windows JVM itself; the supplied Windows log is the evidence that its OS/JDK mapping produced `Asia/Saigon`.
@@ -1,23 +1,43 @@
package com.lab.labtimesheet; package com.lab.labtimesheet;
import java.util.TimeZone;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import com.lab.labtimesheet.config.SecurityProperties; import com.lab.labtimesheet.config.SecurityProperties;
/** Application entry point and root component-scan boundary for Lab Timesheet. */ /**
* Application entry point and root component-scan boundary for Lab Timesheet.
*
* <p>The entry point also canonicalizes the legacy Windows Vietnam timezone alias before database
* drivers inspect the JVM default timezone.
*/
@SpringBootApplication @SpringBootApplication
@EnableConfigurationProperties(SecurityProperties.class) @EnableConfigurationProperties(SecurityProperties.class)
public class LabtimesheetApplication { public class LabtimesheetApplication {
private static final String LEGACY_VIETNAM_TIME_ZONE = "Asia/Saigon";
private static final String BUSINESS_TIME_ZONE = "Asia/Ho_Chi_Minh";
/** /**
* Starts the standalone Spring Boot process. * Canonicalizes the process timezone and starts the standalone Spring Boot process.
* *
* @param args command-line arguments forwarded to Spring Boot * @param args command-line arguments forwarded to Spring Boot
*/ */
public static void main(String[] args) { public static void main(String[] args) {
normalizeDefaultTimeZone();
SpringApplication.run(LabtimesheetApplication.class, args); SpringApplication.run(LabtimesheetApplication.class, args);
} }
/**
* Replaces the legacy Windows Vietnam alias before pgJDBC sends it to PostgreSQL as a startup
* parameter. Other supported system timezones remain unchanged.
*/
static void normalizeDefaultTimeZone() {
if (LEGACY_VIETNAM_TIME_ZONE.equals(TimeZone.getDefault().getID())) {
TimeZone.setDefault(TimeZone.getTimeZone(BUSINESS_TIME_ZONE));
}
}
} }
@@ -0,0 +1,80 @@
package com.lab.labtimesheet;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mockStatic;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.TimeZone;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springframework.boot.SpringApplication;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
class ApplicationTimeZoneIntegrationTest {
@Test
void mainCanonicalizesLegacyAliasBeforeStartingSpring() {
TimeZone originalTimeZone = TimeZone.getDefault();
String[] args = {"--spring.profiles.active=test"};
try (MockedStatic<SpringApplication> springApplication = mockStatic(SpringApplication.class)) {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Saigon"));
LabtimesheetApplication.main(args);
assertThat(TimeZone.getDefault().getID()).isEqualTo("Asia/Ho_Chi_Minh");
springApplication.verify(() -> SpringApplication.run(LabtimesheetApplication.class, args));
} finally {
TimeZone.setDefault(originalTimeZone);
}
}
@Test
void canonicalizesLegacyVietnamAliasBeforePostgresConnects() throws SQLException {
TimeZone originalTimeZone = TimeZone.getDefault();
try (PostgreSQLContainer postgres =
new PostgreSQLContainer(DockerImageName.parse("postgres:18.4"))) {
postgres.start();
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Saigon"));
assertThat(TimeZone.getDefault().getID()).isEqualTo("Asia/Saigon");
assertThatThrownBy(() -> openConnection(postgres))
.isInstanceOf(SQLException.class)
.hasMessageContaining("Asia/Saigon");
LabtimesheetApplication.normalizeDefaultTimeZone();
assertThat(TimeZone.getDefault().getID()).isEqualTo("Asia/Ho_Chi_Minh");
try (Connection connection = openConnection(postgres)) {
assertThat(connection.isValid(1)).isTrue();
}
} finally {
TimeZone.setDefault(originalTimeZone);
}
}
@Test
void leavesSupportedSystemTimeZoneUnchanged() {
TimeZone originalTimeZone = TimeZone.getDefault();
try {
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Paris"));
LabtimesheetApplication.normalizeDefaultTimeZone();
assertThat(TimeZone.getDefault().getID()).isEqualTo("Europe/Paris");
} finally {
TimeZone.setDefault(originalTimeZone);
}
}
private static Connection openConnection(PostgreSQLContainer postgres) throws SQLException {
return DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
}
}