Compare commits

...
Author SHA1 Message Date
DarioGii 5f9f4be39f adding import/export support for postgresql 2025-10-17 16:23:55 +01:00
2 changed files with 350 additions and 35 deletions
@@ -36,6 +36,12 @@ import stirling.software.proprietary.security.model.exception.BackupNotFoundExce
@Service
public class DatabaseService implements DatabaseServiceInterface {
private enum Dialect {
H2,
POSTGRES,
UNKNOWN
}
public static final String BACKUP_PREFIX = "backup_";
public static final String SQL_SUFFIX = ".sql";
private final Path BACKUP_DIR;
@@ -45,10 +51,26 @@ public class DatabaseService implements DatabaseServiceInterface {
public DatabaseService(
ApplicationProperties.Datasource datasourceProps, DataSource dataSource) {
this.BACKUP_DIR =
Paths.get(InstallationPathConfig.getConfigPath(), "db", "backup").normalize();
this.datasourceProps = datasourceProps;
this.dataSource = dataSource;
this.BACKUP_DIR = resolveBackupDir(datasourceProps);
}
private Path resolveBackupDir(ApplicationProperties.Datasource ds) {
String base = InstallationPathConfig.getConfigPath();
String sub = "unknown";
boolean custom = ds.isEnableCustomDatabase();
String type = ds.getType() == null ? "" : ds.getType().toUpperCase();
String url =
ds.getCustomDatabaseUrl() == null ? "" : ds.getCustomDatabaseUrl().toLowerCase();
if (!custom) {
sub = "h2";
} else if ("H2".equals(type) && url.contains("h2")) {
sub = "h2";
} else if ("POSTGRESQL".equals(type) && url.contains("postgres")) {
sub = "postgres";
}
return Paths.get(base, "db", "backup", sub).normalize();
}
/**
@@ -77,38 +99,33 @@ public class DatabaseService implements DatabaseServiceInterface {
public List<FileInfo> getBackupList() {
List<FileInfo> backupFiles = new ArrayList<>();
if (isH2Database()) {
createBackupDirectory();
createBackupDirectory();
try (DirectoryStream<Path> stream =
Files.newDirectoryStream(
BACKUP_DIR,
path ->
path.getFileName().toString().startsWith(BACKUP_PREFIX)
&& path.getFileName()
.toString()
.endsWith(SQL_SUFFIX))) {
for (Path entry : stream) {
BasicFileAttributes attrs =
Files.readAttributes(entry, BasicFileAttributes.class);
LocalDateTime modificationDate =
LocalDateTime.ofInstant(
attrs.lastModifiedTime().toInstant(), ZoneId.systemDefault());
LocalDateTime creationDate =
LocalDateTime.ofInstant(
attrs.creationTime().toInstant(), ZoneId.systemDefault());
long fileSize = attrs.size();
backupFiles.add(
new FileInfo(
entry.getFileName().toString(),
entry.toString(),
modificationDate,
fileSize,
creationDate));
}
} catch (IOException e) {
log.error("Error reading backup directory: {}", e.getMessage(), e);
try (DirectoryStream<Path> stream =
Files.newDirectoryStream(
BACKUP_DIR,
path ->
path.getFileName().toString().startsWith(BACKUP_PREFIX)
&& path.getFileName().toString().endsWith(SQL_SUFFIX))) {
for (Path entry : stream) {
BasicFileAttributes attrs = Files.readAttributes(entry, BasicFileAttributes.class);
LocalDateTime modificationDate =
LocalDateTime.ofInstant(
attrs.lastModifiedTime().toInstant(), ZoneId.systemDefault());
LocalDateTime creationDate =
LocalDateTime.ofInstant(
attrs.creationTime().toInstant(), ZoneId.systemDefault());
long fileSize = attrs.size();
backupFiles.add(
new FileInfo(
entry.getFileName().toString(),
entry.toString(),
modificationDate,
fileSize,
creationDate));
}
} catch (IOException e) {
log.error("Error reading backup directory: {}", e.getMessage(), e);
}
return backupFiles;
@@ -195,6 +212,56 @@ public class DatabaseService implements DatabaseServiceInterface {
}
log.info("Database export completed: {}", insertOutputFilePath);
} else if (isPostgresDatabase()) {
// Use pg_dump to export PostgreSQL database
List<String> command = new ArrayList<>();
command.add("pg_dump");
if (datasourceProps.getHostName() != null) {
command.add("-h");
command.add(datasourceProps.getHostName());
}
if (datasourceProps.getPort() != null) {
command.add("-p");
command.add(String.valueOf(datasourceProps.getPort()));
}
if (datasourceProps.getUsername() != null) {
command.add("-U");
command.add(datasourceProps.getUsername());
}
command.add("-F");
command.add("p"); // plain SQL
command.add("--inserts");
command.add("-f");
command.add(insertOutputFilePath.toString());
// Database name
if (datasourceProps.getName() != null) {
command.add(datasourceProps.getName());
}
ProcessBuilder pb = new ProcessBuilder(command);
// Set password via env var to avoid prompt
if (datasourceProps.getPassword() != null) {
pb.environment().put("PGPASSWORD", datasourceProps.getPassword());
}
pb.redirectErrorStream(true);
try {
Process process = pb.start();
int exit = process.waitFor();
if (exit != 0) {
log.error(
"pg_dump exited with code {} while exporting to {}",
exit,
insertOutputFilePath);
} else {
log.info("Database export completed: {}", insertOutputFilePath);
}
} catch (IOException | InterruptedException e) {
Thread.currentThread().interrupt();
log.error("Error during PostgreSQL database export: {}", e.getMessage(), e);
}
} else {
log.warn(
"Database export not implemented for this driver. Only H2 and PostgreSQL are supported.");
}
}
@@ -240,9 +307,7 @@ public class DatabaseService implements DatabaseServiceInterface {
private boolean isH2Database() {
boolean isTypeH2 =
datasourceProps.getType().equalsIgnoreCase(ApplicationProperties.Driver.H2.name());
boolean isDBUrlH2 =
datasourceProps.getCustomDatabaseUrl().contains("h2")
|| datasourceProps.getCustomDatabaseUrl().contains("H2");
boolean isDBUrlH2 = datasourceProps.getCustomDatabaseUrl().toLowerCase().contains("h2");
boolean isCustomDatabase = datasourceProps.isEnableCustomDatabase();
if (isCustomDatabase) {
@@ -267,6 +332,35 @@ public class DatabaseService implements DatabaseServiceInterface {
return !isCustomDatabase || isH2;
}
private boolean isPostgresDatabase() {
boolean isTypePg =
datasourceProps
.getType()
.equalsIgnoreCase(ApplicationProperties.Driver.POSTGRESQL.name());
String url =
datasourceProps.getCustomDatabaseUrl() == null
? ""
: datasourceProps.getCustomDatabaseUrl();
boolean isDBUrlPg = url.toLowerCase().contains("postgres");
boolean isCustomDatabase = datasourceProps.isEnableCustomDatabase();
if (isCustomDatabase) {
if (isTypePg && !isDBUrlPg) {
log.warn(
"Datasource type is POSTGRESQL, but the URL does not contain 'postgres'. Please check your configuration.");
throw new IllegalStateException(
"Datasource type is POSTGRESQL, but the URL does not contain 'postgres'. Please check your configuration.");
} else if (!isTypePg && isDBUrlPg) {
log.warn(
"Datasource URL contains 'postgres', but the type is not POSTGRESQL. Please check your configuration.");
throw new IllegalStateException(
"Datasource URL contains 'postgres', but the type is not POSTGRESQL. Please check your configuration.");
}
}
boolean isPg = isTypePg && isDBUrlPg;
return isCustomDatabase && isPg;
}
/**
* Deletes a backup file.
*
@@ -314,6 +408,49 @@ public class DatabaseService implements DatabaseServiceInterface {
} catch (ScriptException e) {
log.error("Error: File {} not found", scriptPath.toString(), e);
}
} else if (isPostgresDatabase()) {
List<String> command = new ArrayList<>();
command.add("psql");
if (datasourceProps.getHostName() != null) {
command.add("-h");
command.add(datasourceProps.getHostName());
}
if (datasourceProps.getPort() != null) {
command.add("-p");
command.add(String.valueOf(datasourceProps.getPort()));
}
if (datasourceProps.getUsername() != null) {
command.add("-U");
command.add(datasourceProps.getUsername());
}
if (datasourceProps.getName() != null) {
command.add("-d");
command.add(datasourceProps.getName());
}
command.add("-f");
command.add(scriptPath.toString());
ProcessBuilder pb = new ProcessBuilder(command);
if (datasourceProps.getPassword() != null) {
pb.environment().put("PGPASSWORD", datasourceProps.getPassword());
}
pb.redirectErrorStream(true);
try {
Process process = pb.start();
int exit = process.waitFor();
if (exit != 0) {
log.error("psql exited with code {} while importing from {}", exit, scriptPath);
} else {
log.info("Database import completed: {}", scriptPath);
}
} catch (IOException | InterruptedException e) {
Thread.currentThread().interrupt();
log.error("Error during PostgreSQL database import: {}", e.getMessage(), e);
}
return;
}
log.info("Database import completed: {}", scriptPath);
@@ -0,0 +1,178 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.sql.SQLException;
import javax.sql.DataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
@ExtendWith(MockitoExtension.class)
public class DatabaseServiceTest {
private Path tempBaseDir;
private MockedStatic<InstallationPathConfig> mockedInstallationConfig;
@Mock private DataSource dataSource;
private ApplicationProperties.Datasource h2DatasourceProps;
private ApplicationProperties.Datasource pgDatasourceProps;
@BeforeEach
void setup() throws IOException {
tempBaseDir = Files.createTempDirectory("dbservice-test-");
mockedInstallationConfig = Mockito.mockStatic(InstallationPathConfig.class);
mockedInstallationConfig
.when(InstallationPathConfig::getConfigPath)
.thenReturn(tempBaseDir.toString());
// H2 default (custom disabled)
h2DatasourceProps = new ApplicationProperties.Datasource();
h2DatasourceProps.setEnableCustomDatabase(false);
h2DatasourceProps.setType(ApplicationProperties.Driver.H2.name());
h2DatasourceProps.setCustomDatabaseUrl("jdbc:h2:file:./data");
// PostgreSQL custom enabled
pgDatasourceProps = new ApplicationProperties.Datasource();
pgDatasourceProps.setEnableCustomDatabase(true);
pgDatasourceProps.setType(ApplicationProperties.Driver.POSTGRESQL.name());
pgDatasourceProps.setCustomDatabaseUrl("jdbc:postgresql://localhost:5432/mydb");
pgDatasourceProps.setHostName("localhost");
pgDatasourceProps.setPort(5432);
pgDatasourceProps.setName("mydb");
pgDatasourceProps.setUsername("user");
pgDatasourceProps.setPassword("pass");
}
@AfterEach
void tearDown() throws IOException {
if (mockedInstallationConfig != null) {
mockedInstallationConfig.close();
}
if (tempBaseDir != null) {
// Cleanup temp files
Files.walk(tempBaseDir)
.sorted((a, b) -> b.getNameCount() - a.getNameCount())
.forEach(
p -> {
try {
Files.deleteIfExists(p);
} catch (IOException ignored) {
}
});
}
}
@Test
void backupDirectoryResolution_h2() {
DatabaseService service = new DatabaseService(h2DatasourceProps, dataSource);
Path path = service.getBackupFilePath("backup_test.sql");
assertTrue(
path.toString().contains("/db/backup/h2/")
|| path.toString().contains("\\db\\backup\\h2\\"),
"Backup directory should resolve to h2 subfolder");
assertTrue(Files.exists(path.getParent()), "Backup directory should be created");
}
@Test
void backupDirectoryResolution_postgres() {
DatabaseService service = new DatabaseService(pgDatasourceProps, dataSource);
Path path = service.getBackupFilePath("backup_test.sql");
assertTrue(
path.toString().contains("/db/backup/postgres/")
|| path.toString().contains("\\db\\backup\\postgres\\"),
"Backup directory should resolve to postgres subfolder");
assertTrue(Files.exists(path.getParent()), "Backup directory should be created");
}
@Test
void hasBackup_false_then_true() throws IOException {
DatabaseService service = new DatabaseService(h2DatasourceProps, dataSource);
// Initially no backups
assertFalse(service.hasBackup());
// Create a valid backup file
Path backup = service.getBackupFilePath("backup_202001010000.sql");
Files.createDirectories(backup.getParent());
Files.createFile(backup);
assertTrue(service.hasBackup());
}
@Test
void getBackupFilePath_preventsPathTraversal() {
DatabaseService service = new DatabaseService(h2DatasourceProps, dataSource);
assertThrows(SecurityException.class, () -> service.getBackupFilePath("../evil.sql"));
}
@Test
void importDatabaseFromUI_copiesAndDeletesTemp_evenIfExecutionFails() throws Exception {
// Arrange H2 branch; mock connection acquisition to throw so that executeDatabaseScript
// logs error
when(dataSource.getConnection()).thenThrow(new SQLException("No DB in unit test"));
DatabaseService service = new DatabaseService(h2DatasourceProps, dataSource);
Path tempSql = Files.createTempFile("temp-import-", ".sql");
Files.writeString(tempSql, "-- test content");
// Act
boolean result = service.importDatabaseFromUI(tempSql);
// Assert
assertTrue(result, "Method should return true regardless of execution outcome");
assertFalse(
Files.exists(tempSql), "Temporary uploaded file should be deleted after import");
// The copied file should exist in backup directory with user_ prefix
Path parent = Files.createDirectories(service.getBackupFilePath("dummy.sql").getParent());
// find a file starting with backup_user_
boolean found =
Files.list(parent)
.anyMatch(
p ->
p.getFileName().toString().startsWith("backup_user_")
&& p.getFileName().toString().endsWith(".sql"));
assertTrue(found, "A user_ prefixed backup copy should be created");
}
@Test
void getBackupList_sortsByModificationDateDesc() throws Exception {
DatabaseService service = new DatabaseService(h2DatasourceProps, dataSource);
Path dir = service.getBackupFilePath("backup_dummy.sql").getParent();
Files.createDirectories(dir);
Path older = dir.resolve("backup_202001010000.sql");
Path newer = dir.resolve("backup_202001020000.sql");
Files.writeString(older, "-- old");
Files.writeString(newer, "-- new");
Files.setLastModifiedTime(older, FileTime.fromMillis(1_000L));
Files.setLastModifiedTime(newer, FileTime.fromMillis(2_000L));
var list = service.getBackupList();
assertEquals(2, list.size());
// Newest should be first after we sort in importDatabase, but getBackupList returns
// unsorted.
// We'll verify importDatabase picks the latest by modification time by creating a no-op
// DataSource
when(dataSource.getConnection()).thenThrow(new SQLException("No DB in unit test"));
service.importDatabase();
// If no exception thrown, method executed and chose a file; functional behavior verified by
// no crash.
}
}