refactor: use static HIS database per instance
This commit is contained in:
@@ -22,29 +22,22 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bootstrapCtx, bootstrapCancel := context.WithTimeout(context.Background(), 20*time.Second)
|
bootstrapCtx, bootstrapCancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
registry, err := databaseconfig.NewRegistry(bootstrapCtx, databaseconfig.Setting{
|
resolver, err := databaseconfig.NewStaticResolver(bootstrapCtx, cfg.RSCode, databaseconfig.Setting{
|
||||||
Host: cfg.ManagementDBHost, Port: cfg.ManagementDBPort, Database: cfg.ManagementDBDatabase,
|
Host: cfg.HISDBHost, Port: cfg.HISDBPort, Database: cfg.HISDBDatabase,
|
||||||
Username: cfg.ManagementDBUsername, Password: cfg.ManagementDBPassword,
|
Username: cfg.HISDBUsername, Password: cfg.HISDBPassword,
|
||||||
}, databaseconfig.PoolConfig{
|
}, databaseconfig.PoolConfig{
|
||||||
MaxOpenConns: cfg.DBMaxOpenConns, MaxIdleConns: cfg.DBMaxIdleConns,
|
MaxOpenConns: cfg.DBMaxOpenConns, MaxIdleConns: cfg.DBMaxIdleConns,
|
||||||
ConnMaxLifetime: cfg.DBConnMaxLifetime,
|
ConnMaxLifetime: cfg.DBConnMaxLifetime,
|
||||||
}, cfg.DBCredentialSecret)
|
})
|
||||||
bootstrapCancel()
|
bootstrapCancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("load database registry: %v", err)
|
log.Fatalf("load database HIS: %v", err)
|
||||||
}
|
}
|
||||||
defer registry.Close()
|
defer resolver.Close()
|
||||||
|
|
||||||
handler := apphttp.NewDynamicHandler()
|
handler := apphttp.NewDynamicHandler(cfg.RSCode)
|
||||||
settingsHandler := apphttp.NewDatabaseSettingsHandler(registry)
|
|
||||||
protected := func(handler http.Handler) http.Handler {
|
protected := func(handler http.Handler) http.Handler {
|
||||||
return apphttp.BearerAuth(cfg.BearerToken, apphttp.SelectDatabase(registry, handler))
|
return apphttp.BearerAuth(cfg.BearerToken, apphttp.SelectDatabase(resolver, handler))
|
||||||
}
|
|
||||||
adminProtected := func(handler http.Handler) http.Handler {
|
|
||||||
return apphttp.BearerAuth(cfg.DBSettingsToken, handler)
|
|
||||||
}
|
|
||||||
catalogProtected := func(handler http.Handler) http.Handler {
|
|
||||||
return apphttp.BearerAuth(cfg.BearerToken, handler)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@@ -64,10 +57,6 @@ func main() {
|
|||||||
mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/services", protected(http.HandlerFunc(handler.AddLabServices)))
|
mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/services", protected(http.HandlerFunc(handler.AddLabServices)))
|
||||||
mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/packages", protected(http.HandlerFunc(handler.AddLabPackage)))
|
mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/packages", protected(http.HandlerFunc(handler.AddLabPackage)))
|
||||||
mux.Handle("GET /api/cpone/patients/lab-results/{labNumber}", protected(http.HandlerFunc(handler.GetLabResult)))
|
mux.Handle("GET /api/cpone/patients/lab-results/{labNumber}", protected(http.HandlerFunc(handler.GetLabResult)))
|
||||||
mux.Handle("GET /api/cpone/database-settings", adminProtected(http.HandlerFunc(settingsHandler.List)))
|
|
||||||
mux.Handle("GET /api/cpone/database-settings/{kodeRs}", adminProtected(http.HandlerFunc(settingsHandler.Get)))
|
|
||||||
mux.Handle("POST /api/cpone/database-settings", adminProtected(http.HandlerFunc(settingsHandler.Store)))
|
|
||||||
mux.Handle("GET /api/cpone/hospitals", catalogProtected(http.HandlerFunc(settingsHandler.ListHospitals)))
|
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|||||||
@@ -10,19 +10,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AppHost string
|
AppHost string
|
||||||
AppPort string
|
AppPort string
|
||||||
BearerToken string
|
RSCode string
|
||||||
DBSettingsToken string
|
BearerToken string
|
||||||
DBMaxOpenConns int
|
DBMaxOpenConns int
|
||||||
DBMaxIdleConns int
|
DBMaxIdleConns int
|
||||||
DBConnMaxLifetime time.Duration
|
DBConnMaxLifetime time.Duration
|
||||||
ManagementDBHost string
|
HISDBHost string
|
||||||
ManagementDBPort string
|
HISDBPort string
|
||||||
ManagementDBDatabase string
|
HISDBDatabase string
|
||||||
ManagementDBUsername string
|
HISDBUsername string
|
||||||
ManagementDBPassword string
|
HISDBPassword string
|
||||||
DBCredentialSecret string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(path string) (Config, error) {
|
func Load(path string) (Config, error) {
|
||||||
@@ -31,26 +30,28 @@ func Load(path string) (Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
AppHost: env("APP_HOST", "0.0.0.0"),
|
AppHost: env("APP_HOST", "0.0.0.0"),
|
||||||
AppPort: env("APP_PORT", "8080"),
|
AppPort: env("APP_PORT", "8080"),
|
||||||
BearerToken: env("CPONE_BEARER_TOKEN", ""),
|
RSCode: env("CPONE_RS_CODE", ""),
|
||||||
DBSettingsToken: env("CPONE_DATABASE_SETTINGS_TOKEN", env("CPONE_BEARER_TOKEN", "")),
|
BearerToken: env("CPONE_BEARER_TOKEN", ""),
|
||||||
DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 25),
|
DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 25),
|
||||||
DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 25),
|
DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 25),
|
||||||
DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MINUTES", 5)) * time.Minute,
|
DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MINUTES", 5)) * time.Minute,
|
||||||
ManagementDBHost: env("CPONE_DB_HOST", "127.0.0.1"),
|
HISDBHost: env("CPONE_DB_HOST", "127.0.0.1"),
|
||||||
ManagementDBPort: env("CPONE_DB_PORT", "3306"),
|
HISDBPort: env("CPONE_DB_PORT", "3306"),
|
||||||
ManagementDBDatabase: env("CPONE_DB_DATABASE", "cpone_middleware"),
|
HISDBDatabase: env("CPONE_DB_DATABASE", ""),
|
||||||
ManagementDBUsername: env("CPONE_DB_USERNAME", "root"),
|
HISDBUsername: env("CPONE_DB_USERNAME", ""),
|
||||||
ManagementDBPassword: env("CPONE_DB_PASSWORD", ""),
|
HISDBPassword: env("CPONE_DB_PASSWORD", ""),
|
||||||
DBCredentialSecret: env("CPONE_DATABASE_CREDENTIAL_KEY", env("CPONE_DATABASE_SETTINGS_TOKEN", env("CPONE_BEARER_TOKEN", ""))),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.RSCode == "" {
|
||||||
|
return Config{}, fmt.Errorf("CPONE_RS_CODE wajib diisi")
|
||||||
|
}
|
||||||
if cfg.BearerToken == "" {
|
if cfg.BearerToken == "" {
|
||||||
return Config{}, fmt.Errorf("CPONE_BEARER_TOKEN wajib diisi")
|
return Config{}, fmt.Errorf("CPONE_BEARER_TOKEN wajib diisi")
|
||||||
}
|
}
|
||||||
if cfg.ManagementDBDatabase == "" {
|
if cfg.HISDBDatabase == "" || cfg.HISDBUsername == "" {
|
||||||
return Config{}, fmt.Errorf("CPONE_DB_DATABASE wajib diisi")
|
return Config{}, fmt.Errorf("CPONE_DB_DATABASE dan CPONE_DB_USERNAME wajib diisi")
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
|
|||||||
@@ -1,443 +0,0 @@
|
|||||||
package databaseconfig
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
mysql "github.com/go-sql-driver/mysql"
|
|
||||||
|
|
||||||
"primaya-api/cpone-middleware/internal/repository"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrSettingNotFound = errors.New("database setting not found")
|
|
||||||
|
|
||||||
var (
|
|
||||||
validRSCode = regexp.MustCompile(`^[A-Z0-9_-]+$`)
|
|
||||||
validDatabaseName = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
|
|
||||||
)
|
|
||||||
|
|
||||||
type Setting struct {
|
|
||||||
RSCode string `json:"kode_rs"`
|
|
||||||
Name string `json:"nama"`
|
|
||||||
Host string `json:"host"`
|
|
||||||
Port string `json:"port"`
|
|
||||||
Database string `json:"database"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PublicSetting struct {
|
|
||||||
RSCode string `json:"kode_rs"`
|
|
||||||
Name string `json:"nama"`
|
|
||||||
Host string `json:"host"`
|
|
||||||
Port string `json:"port"`
|
|
||||||
Database string `json:"database"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
HasPassword bool `json:"has_password"`
|
|
||||||
Active bool `json:"active"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PoolConfig struct {
|
|
||||||
MaxOpenConns int
|
|
||||||
MaxIdleConns int
|
|
||||||
ConnMaxLifetime time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
type Registry struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
managementDB *sql.DB
|
|
||||||
poolConfig PoolConfig
|
|
||||||
credentialKey [32]byte
|
|
||||||
settings map[string]Setting
|
|
||||||
pools map[string]*sql.DB
|
|
||||||
retiredPools []*sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRegistry creates the management database and tables, then loads all
|
|
||||||
// active hospital database settings.
|
|
||||||
func NewRegistry(ctx context.Context, managementSetting Setting, poolConfig PoolConfig, credentialSecret string) (*Registry, error) {
|
|
||||||
if !validDatabaseName.MatchString(managementSetting.Database) {
|
|
||||||
return nil, errors.New("nama database manajemen hanya boleh berisi huruf, angka, dan underscore")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(credentialSecret) == "" {
|
|
||||||
return nil, errors.New("credential secret database wajib diisi")
|
|
||||||
}
|
|
||||||
|
|
||||||
managementDB, err := openManagementDatabase(ctx, managementSetting, poolConfig)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r := &Registry{
|
|
||||||
managementDB: managementDB,
|
|
||||||
poolConfig: poolConfig,
|
|
||||||
credentialKey: sha256.Sum256([]byte(credentialSecret)),
|
|
||||||
settings: make(map[string]Setting),
|
|
||||||
pools: make(map[string]*sql.DB),
|
|
||||||
}
|
|
||||||
if err := r.migrate(ctx); err != nil {
|
|
||||||
_ = managementDB.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := r.load(ctx); err != nil {
|
|
||||||
_ = managementDB.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func NormalizeRSCode(code string) string { return strings.ToUpper(strings.TrimSpace(code)) }
|
|
||||||
|
|
||||||
func (r *Registry) Resolve(ctx context.Context, code string) (repository.MySQLLayananRepository, error) {
|
|
||||||
code = NormalizeRSCode(code)
|
|
||||||
r.mu.RLock()
|
|
||||||
setting, exists := r.settings[code]
|
|
||||||
db := r.pools[code]
|
|
||||||
r.mu.RUnlock()
|
|
||||||
if !exists {
|
|
||||||
return repository.MySQLLayananRepository{}, ErrSettingNotFound
|
|
||||||
}
|
|
||||||
if db != nil {
|
|
||||||
return repository.NewMySQLLayananRepository(db), nil
|
|
||||||
}
|
|
||||||
newDB, err := r.openAndPing(ctx, setting)
|
|
||||||
if err != nil {
|
|
||||||
return repository.MySQLLayananRepository{}, err
|
|
||||||
}
|
|
||||||
r.mu.Lock()
|
|
||||||
if existing := r.pools[code]; existing != nil {
|
|
||||||
r.mu.Unlock()
|
|
||||||
_ = newDB.Close()
|
|
||||||
return repository.NewMySQLLayananRepository(existing), nil
|
|
||||||
}
|
|
||||||
r.pools[code] = newDB
|
|
||||||
r.mu.Unlock()
|
|
||||||
return repository.NewMySQLLayananRepository(newDB), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Registry) Upsert(ctx context.Context, setting Setting) (PublicSetting, bool, error) {
|
|
||||||
setting = normalizeSetting(setting)
|
|
||||||
if err := Validate(setting); err != nil {
|
|
||||||
return PublicSetting{}, false, err
|
|
||||||
}
|
|
||||||
newDB, err := r.openAndPing(ctx, setting)
|
|
||||||
if err != nil {
|
|
||||||
return PublicSetting{}, false, fmt.Errorf("koneksi database RS gagal: %w", err)
|
|
||||||
}
|
|
||||||
encryptedPassword, err := r.encrypt(setting.Password)
|
|
||||||
if err != nil {
|
|
||||||
_ = newDB.Close()
|
|
||||||
return PublicSetting{}, false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := r.managementDB.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
_ = newDB.Close()
|
|
||||||
return PublicSetting{}, false, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
var hospitalID int64
|
|
||||||
err = tx.QueryRowContext(ctx, `SELECT id FROM hospitals WHERE code = ? FOR UPDATE`, setting.RSCode).Scan(&hospitalID)
|
|
||||||
created := errors.Is(err, sql.ErrNoRows)
|
|
||||||
if err != nil && !created {
|
|
||||||
_ = newDB.Close()
|
|
||||||
return PublicSetting{}, false, err
|
|
||||||
}
|
|
||||||
if created {
|
|
||||||
result, execErr := tx.ExecContext(ctx, `INSERT INTO hospitals (code, name, active) VALUES (?, ?, 1)`, setting.RSCode, setting.Name)
|
|
||||||
if execErr != nil {
|
|
||||||
_ = newDB.Close()
|
|
||||||
return PublicSetting{}, false, execErr
|
|
||||||
}
|
|
||||||
hospitalID, err = result.LastInsertId()
|
|
||||||
if err != nil {
|
|
||||||
_ = newDB.Close()
|
|
||||||
return PublicSetting{}, false, err
|
|
||||||
}
|
|
||||||
} else if _, err = tx.ExecContext(ctx, `UPDATE hospitals SET name = ?, active = 1 WHERE id = ?`, setting.Name, hospitalID); err != nil {
|
|
||||||
_ = newDB.Close()
|
|
||||||
return PublicSetting{}, false, err
|
|
||||||
}
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO hospital_databases (hospital_id, host, port, database_name, username, encrypted_password)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE host = VALUES(host), port = VALUES(port), database_name = VALUES(database_name),
|
|
||||||
username = VALUES(username), encrypted_password = VALUES(encrypted_password)`,
|
|
||||||
hospitalID, setting.Host, setting.Port, setting.Database, setting.Username, encryptedPassword)
|
|
||||||
if err != nil {
|
|
||||||
_ = newDB.Close()
|
|
||||||
return PublicSetting{}, false, err
|
|
||||||
}
|
|
||||||
if err = tx.Commit(); err != nil {
|
|
||||||
_ = newDB.Close()
|
|
||||||
return PublicSetting{}, false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
r.mu.Lock()
|
|
||||||
oldPool := r.pools[setting.RSCode]
|
|
||||||
r.settings[setting.RSCode] = setting
|
|
||||||
r.pools[setting.RSCode] = newDB
|
|
||||||
if oldPool != nil {
|
|
||||||
r.retiredPools = append(r.retiredPools, oldPool)
|
|
||||||
}
|
|
||||||
r.mu.Unlock()
|
|
||||||
return publicSetting(setting), created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Registry) List() []PublicSetting {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
result := make([]PublicSetting, 0, len(r.settings))
|
|
||||||
for _, setting := range r.settings {
|
|
||||||
result = append(result, publicSetting(setting))
|
|
||||||
}
|
|
||||||
sort.Slice(result, func(i, j int) bool { return result[i].RSCode < result[j].RSCode })
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Registry) Get(code string) (PublicSetting, bool) {
|
|
||||||
code = NormalizeRSCode(code)
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
setting, exists := r.settings[code]
|
|
||||||
return publicSetting(setting), exists
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Registry) Close() error {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
var firstErr error
|
|
||||||
for code, db := range r.pools {
|
|
||||||
if err := db.Close(); err != nil && firstErr == nil {
|
|
||||||
firstErr = err
|
|
||||||
}
|
|
||||||
delete(r.pools, code)
|
|
||||||
}
|
|
||||||
for _, db := range r.retiredPools {
|
|
||||||
if err := db.Close(); err != nil && firstErr == nil {
|
|
||||||
firstErr = err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := r.managementDB.Close(); err != nil && firstErr == nil {
|
|
||||||
firstErr = err
|
|
||||||
}
|
|
||||||
r.retiredPools = nil
|
|
||||||
return firstErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func openManagementDatabase(ctx context.Context, setting Setting, pool PoolConfig) (*sql.DB, error) {
|
|
||||||
serverSetting := setting
|
|
||||||
serverSetting.Database = ""
|
|
||||||
bootstrap, err := sql.Open("mysql", mysqlDSN(serverSetting))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := bootstrap.PingContext(ctx); err != nil {
|
|
||||||
_ = bootstrap.Close()
|
|
||||||
return nil, fmt.Errorf("koneksi server database manajemen gagal: %w", err)
|
|
||||||
}
|
|
||||||
_, err = bootstrap.ExecContext(ctx, "CREATE DATABASE IF NOT EXISTS `"+setting.Database+"` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
|
|
||||||
_ = bootstrap.Close()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("buat database manajemen %s: %w", setting.Database, err)
|
|
||||||
}
|
|
||||||
db, err := sql.Open("mysql", mysqlDSN(setting))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
applyPoolConfig(db, pool)
|
|
||||||
if err := db.PingContext(ctx); err != nil {
|
|
||||||
_ = db.Close()
|
|
||||||
return nil, fmt.Errorf("koneksi database manajemen gagal: %w", err)
|
|
||||||
}
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Registry) migrate(ctx context.Context) error {
|
|
||||||
statements := []string{
|
|
||||||
`CREATE TABLE IF NOT EXISTS hospitals (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, code VARCHAR(50) NOT NULL, name VARCHAR(150) NOT NULL,
|
|
||||||
active TINYINT(1) NOT NULL DEFAULT 1,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (id), UNIQUE KEY uq_hospitals_code (code), KEY idx_hospitals_active (active)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
||||||
`CREATE TABLE IF NOT EXISTS hospital_databases (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, hospital_id BIGINT UNSIGNED NOT NULL,
|
|
||||||
host VARCHAR(255) NOT NULL, port SMALLINT UNSIGNED NOT NULL DEFAULT 3306,
|
|
||||||
database_name VARCHAR(100) NOT NULL, username VARCHAR(100) NOT NULL, encrypted_password TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (id), UNIQUE KEY uq_hospital_databases_hospital (hospital_id),
|
|
||||||
CONSTRAINT fk_hospital_databases_hospital FOREIGN KEY (hospital_id) REFERENCES hospitals(id) ON DELETE CASCADE
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
||||||
}
|
|
||||||
for _, statement := range statements {
|
|
||||||
if _, err := r.managementDB.ExecContext(ctx, statement); err != nil {
|
|
||||||
return fmt.Errorf("migrasi database manajemen: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var defaultColumnExists int
|
|
||||||
if err := r.managementDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.columns
|
|
||||||
WHERE table_schema = DATABASE() AND table_name = 'hospitals' AND column_name = 'is_default'`).Scan(&defaultColumnExists); err != nil {
|
|
||||||
return fmt.Errorf("periksa schema database manajemen: %w", err)
|
|
||||||
}
|
|
||||||
if defaultColumnExists > 0 {
|
|
||||||
if _, err := r.managementDB.ExecContext(ctx, `ALTER TABLE hospitals DROP COLUMN is_default`); err != nil {
|
|
||||||
return fmt.Errorf("hapus kolom default rumah sakit: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Registry) load(ctx context.Context) error {
|
|
||||||
rows, err := r.managementDB.QueryContext(ctx, `SELECT h.code, h.name, d.host, d.port, d.database_name, d.username, d.encrypted_password
|
|
||||||
FROM hospitals h INNER JOIN hospital_databases d ON d.hospital_id = h.id WHERE h.active = 1 ORDER BY h.code`)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
settings := make(map[string]Setting)
|
|
||||||
for rows.Next() {
|
|
||||||
var setting Setting
|
|
||||||
var encryptedPassword string
|
|
||||||
if err := rows.Scan(&setting.RSCode, &setting.Name, &setting.Host, &setting.Port, &setting.Database, &setting.Username, &encryptedPassword); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
setting.Password, err = r.decrypt(encryptedPassword)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("decrypt password %s: %w", setting.RSCode, err)
|
|
||||||
}
|
|
||||||
settings[setting.RSCode] = setting
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
r.settings = settings
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Registry) encrypt(plainText string) (string, error) {
|
|
||||||
block, err := aes.NewCipher(r.credentialKey[:])
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
gcm, err := cipher.NewGCM(block)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
nonce := make([]byte, gcm.NonceSize())
|
|
||||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return base64.StdEncoding.EncodeToString(gcm.Seal(nonce, nonce, []byte(plainText), nil)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Registry) decrypt(encoded string) (string, error) {
|
|
||||||
sealed, err := base64.StdEncoding.DecodeString(encoded)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
block, err := aes.NewCipher(r.credentialKey[:])
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
gcm, err := cipher.NewGCM(block)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if len(sealed) < gcm.NonceSize() {
|
|
||||||
return "", errors.New("encrypted password tidak valid")
|
|
||||||
}
|
|
||||||
plainText, err := gcm.Open(nil, sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():], nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(plainText), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Registry) openAndPing(ctx context.Context, setting Setting) (*sql.DB, error) {
|
|
||||||
db, err := sql.Open("mysql", mysqlDSN(setting))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
applyPoolConfig(db, r.poolConfig)
|
|
||||||
if err := db.PingContext(ctx); err != nil {
|
|
||||||
_ = db.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyPoolConfig(db *sql.DB, pool PoolConfig) {
|
|
||||||
db.SetMaxOpenConns(pool.MaxOpenConns)
|
|
||||||
db.SetMaxIdleConns(pool.MaxIdleConns)
|
|
||||||
db.SetConnMaxLifetime(pool.ConnMaxLifetime)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Validate(setting Setting) error {
|
|
||||||
setting = normalizeSetting(setting)
|
|
||||||
if setting.RSCode == "" {
|
|
||||||
return errors.New("kode_rs wajib diisi")
|
|
||||||
}
|
|
||||||
if len(setting.RSCode) > 50 {
|
|
||||||
return errors.New("kode_rs maksimal 50 karakter")
|
|
||||||
}
|
|
||||||
if !validRSCode.MatchString(setting.RSCode) {
|
|
||||||
return errors.New("kode_rs hanya boleh berisi huruf, angka, tanda hubung, dan underscore")
|
|
||||||
}
|
|
||||||
if setting.Name == "" {
|
|
||||||
return errors.New("nama rumah sakit wajib diisi")
|
|
||||||
}
|
|
||||||
if setting.Host == "" || setting.Port == "" || setting.Database == "" || setting.Username == "" {
|
|
||||||
return errors.New("host, port, database, dan username wajib diisi")
|
|
||||||
}
|
|
||||||
port, err := strconv.Atoi(setting.Port)
|
|
||||||
if err != nil || port < 1 || port > 65535 {
|
|
||||||
return errors.New("port harus berupa angka antara 1 sampai 65535")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeSetting(setting Setting) Setting {
|
|
||||||
setting.RSCode = NormalizeRSCode(setting.RSCode)
|
|
||||||
setting.Name = strings.TrimSpace(setting.Name)
|
|
||||||
setting.Host = strings.TrimSpace(setting.Host)
|
|
||||||
setting.Port = strings.TrimSpace(setting.Port)
|
|
||||||
setting.Database = strings.TrimSpace(setting.Database)
|
|
||||||
setting.Username = strings.TrimSpace(setting.Username)
|
|
||||||
return setting
|
|
||||||
}
|
|
||||||
|
|
||||||
func publicSetting(setting Setting) PublicSetting {
|
|
||||||
return PublicSetting{RSCode: setting.RSCode, Name: setting.Name, Host: setting.Host, Port: setting.Port,
|
|
||||||
Database: setting.Database, Username: setting.Username, HasPassword: setting.Password != "",
|
|
||||||
Active: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mysqlDSN(setting Setting) string {
|
|
||||||
cfg := mysql.NewConfig()
|
|
||||||
cfg.User = setting.Username
|
|
||||||
cfg.Passwd = setting.Password
|
|
||||||
cfg.Net = "tcp"
|
|
||||||
cfg.Addr = net.JoinHostPort(setting.Host, setting.Port)
|
|
||||||
cfg.DBName = setting.Database
|
|
||||||
cfg.ParseTime = true
|
|
||||||
cfg.Loc = time.Local
|
|
||||||
cfg.Params = map[string]string{"charset": "utf8mb4"}
|
|
||||||
return cfg.FormatDSN()
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package databaseconfig
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCredentialEncryptionRoundTrip(t *testing.T) {
|
|
||||||
registry := &Registry{credentialKey: sha256.Sum256([]byte("test-secret"))}
|
|
||||||
encrypted, err := registry.encrypt("database-password")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if encrypted == "database-password" || strings.Contains(encrypted, "database-password") {
|
|
||||||
t.Fatalf("password was not encrypted: %q", encrypted)
|
|
||||||
}
|
|
||||||
decrypted, err := registry.decrypt(encrypted)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if decrypted != "database-password" {
|
|
||||||
t.Fatalf("decrypted = %q", decrypted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCredentialCannotBeDecryptedWithDifferentKey(t *testing.T) {
|
|
||||||
first := &Registry{credentialKey: sha256.Sum256([]byte("first-secret"))}
|
|
||||||
second := &Registry{credentialKey: sha256.Sum256([]byte("second-secret"))}
|
|
||||||
encrypted, err := first.encrypt("database-password")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := second.decrypt(encrypted); err == nil {
|
|
||||||
t.Fatal("expected decryption failure with a different key")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateDatabaseSetting(t *testing.T) {
|
|
||||||
valid := Setting{RSCode: "rs_bekasi", Name: "RS Bekasi", Host: "host", Port: "3306", Database: "db", Username: "user"}
|
|
||||||
if err := Validate(valid); err != nil {
|
|
||||||
t.Fatalf("valid setting rejected: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
mutate func(*Setting)
|
|
||||||
}{
|
|
||||||
{name: "blank code", mutate: func(s *Setting) { s.RSCode = " " }},
|
|
||||||
{name: "invalid code", mutate: func(s *Setting) { s.RSCode = "RS BEKASI" }},
|
|
||||||
{name: "blank hospital name", mutate: func(s *Setting) { s.Name = "" }},
|
|
||||||
{name: "invalid port", mutate: func(s *Setting) { s.Port = "70000" }},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
setting := valid
|
|
||||||
test.mutate(&setting)
|
|
||||||
if err := Validate(setting); err == nil {
|
|
||||||
t.Fatal("expected validation error")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
105
internal/databaseconfig/static.go
Normal file
105
internal/databaseconfig/static.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package databaseconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mysql "github.com/go-sql-driver/mysql"
|
||||||
|
"primaya-api/cpone-middleware/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrRSCodeMismatch indicates that a request was sent to the wrong hospital
|
||||||
|
// instance. The code is a guard for this installation, not a database key.
|
||||||
|
var ErrRSCodeMismatch = errors.New("kode RS tidak sesuai dengan instance CPONE")
|
||||||
|
|
||||||
|
type Setting struct {
|
||||||
|
RSCode string `json:"kode_rs"`
|
||||||
|
Name string `json:"nama"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port string `json:"port"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PoolConfig struct {
|
||||||
|
MaxOpenConns int
|
||||||
|
MaxIdleConns int
|
||||||
|
ConnMaxLifetime time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeRSCode(code string) string { return strings.ToUpper(strings.TrimSpace(code)) }
|
||||||
|
|
||||||
|
// StaticResolver owns one HIS connection for one CPONE installation. Resolve
|
||||||
|
// only validates the request code and always returns the same repository.
|
||||||
|
type StaticResolver struct {
|
||||||
|
code string
|
||||||
|
db *sql.DB
|
||||||
|
repo repository.MySQLLayananRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStaticResolver(ctx context.Context, code string, setting Setting, pool PoolConfig) (*StaticResolver, error) {
|
||||||
|
code = NormalizeRSCode(code)
|
||||||
|
if code == "" {
|
||||||
|
return nil, errors.New("CPONE_RS_CODE wajib diisi")
|
||||||
|
}
|
||||||
|
if setting.Host == "" || setting.Port == "" || setting.Database == "" || setting.Username == "" {
|
||||||
|
return nil, errors.New("host, port, database, dan username HIS wajib diisi")
|
||||||
|
}
|
||||||
|
port, err := strconv.Atoi(setting.Port)
|
||||||
|
if err != nil || port < 1 || port > 65535 {
|
||||||
|
return nil, errors.New("port database HIS harus berupa angka antara 1 sampai 65535")
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("mysql", mysqlDSN(setting))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
applyPoolConfig(db, pool)
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("koneksi database HIS gagal: %w", err)
|
||||||
|
}
|
||||||
|
return &StaticResolver{code: code, db: db, repo: repository.NewMySQLLayananRepository(db)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StaticResolver) Resolve(_ context.Context, code string) (repository.MySQLLayananRepository, error) {
|
||||||
|
if NormalizeRSCode(code) != r.code {
|
||||||
|
return repository.MySQLLayananRepository{}, ErrRSCodeMismatch
|
||||||
|
}
|
||||||
|
return r.repo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StaticResolver) Close() error {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StaticResolver) Code() string { return strings.TrimSpace(r.code) }
|
||||||
|
|
||||||
|
func applyPoolConfig(db *sql.DB, pool PoolConfig) {
|
||||||
|
db.SetMaxOpenConns(pool.MaxOpenConns)
|
||||||
|
db.SetMaxIdleConns(pool.MaxIdleConns)
|
||||||
|
db.SetConnMaxLifetime(pool.ConnMaxLifetime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mysqlDSN(setting Setting) string {
|
||||||
|
cfg := mysql.NewConfig()
|
||||||
|
cfg.User = setting.Username
|
||||||
|
cfg.Passwd = setting.Password
|
||||||
|
cfg.Net = "tcp"
|
||||||
|
cfg.Addr = net.JoinHostPort(setting.Host, setting.Port)
|
||||||
|
cfg.DBName = setting.Database
|
||||||
|
cfg.ParseTime = true
|
||||||
|
cfg.Loc = time.Local
|
||||||
|
cfg.Params = map[string]string{"charset": "utf8mb4"}
|
||||||
|
return cfg.FormatDSN()
|
||||||
|
}
|
||||||
17
internal/databaseconfig/static_test.go
Normal file
17
internal/databaseconfig/static_test.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package databaseconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStaticResolverValidatesCodeWithoutSelectingDatabase(t *testing.T) {
|
||||||
|
resolver := &StaticResolver{code: "RS_BEKASI"}
|
||||||
|
if _, err := resolver.Resolve(context.Background(), " rs_bekasi "); err != nil {
|
||||||
|
t.Fatalf("matching code returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := resolver.Resolve(context.Background(), "RS_JAKARTA"); !errors.Is(err, ErrRSCodeMismatch) {
|
||||||
|
t.Fatalf("wrong code error = %v, want ErrRSCodeMismatch", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"primaya-api/cpone-middleware/internal/databaseconfig"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DatabaseSettingsStore interface {
|
|
||||||
List() []databaseconfig.PublicSetting
|
|
||||||
Get(code string) (databaseconfig.PublicSetting, bool)
|
|
||||||
Upsert(ctx context.Context, setting databaseconfig.Setting) (databaseconfig.PublicSetting, bool, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type DatabaseSettingsHandler struct{ store DatabaseSettingsStore }
|
|
||||||
|
|
||||||
func NewDatabaseSettingsHandler(store DatabaseSettingsStore) DatabaseSettingsHandler {
|
|
||||||
return DatabaseSettingsHandler{store: store}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h DatabaseSettingsHandler) List(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
WriteJSON(w, http.StatusOK, Response{Success: true, Message: "ok", Data: h.store.List()})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h DatabaseSettingsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|
||||||
setting, found := h.store.Get(r.PathValue("kodeRs"))
|
|
||||||
if !found {
|
|
||||||
WriteJSON(w, http.StatusNotFound, Response{Success: false, Message: "Setting database tidak ditemukan", Data: nil})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
WriteJSON(w, http.StatusOK, Response{Success: true, Message: "ok", Data: setting})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h DatabaseSettingsHandler) Store(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var payload databaseconfig.Setting
|
|
||||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
|
||||||
decoder.DisallowUnknownFields()
|
|
||||||
if err := decoder.Decode(&payload); err != nil {
|
|
||||||
WriteJSON(w, http.StatusUnprocessableEntity, Response{Success: false, Message: "Payload JSON tidak valid: " + err.Error(), Data: nil})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := databaseconfig.Validate(payload); err != nil {
|
|
||||||
WriteJSON(w, http.StatusUnprocessableEntity, Response{Success: false, Message: err.Error(), Data: nil})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
setting, created, err := h.store.Upsert(ctx, payload)
|
|
||||||
if err != nil {
|
|
||||||
status := http.StatusInternalServerError
|
|
||||||
message := "Gagal menyimpan setting database"
|
|
||||||
if errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "koneksi database") {
|
|
||||||
status = http.StatusBadGateway
|
|
||||||
message = err.Error()
|
|
||||||
}
|
|
||||||
WriteJSON(w, status, Response{Success: false, Message: message, Data: nil})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
status := http.StatusOK
|
|
||||||
message := "Setting database berhasil diperbarui"
|
|
||||||
if created {
|
|
||||||
status = http.StatusCreated
|
|
||||||
message = "Setting database berhasil ditambahkan"
|
|
||||||
}
|
|
||||||
WriteJSON(w, status, Response{Success: true, Message: message, Data: setting})
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"primaya-api/cpone-middleware/internal/databaseconfig"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeDatabaseSettingsStore struct {
|
|
||||||
setting databaseconfig.Setting
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeDatabaseSettingsStore) List() []databaseconfig.PublicSetting { return nil }
|
|
||||||
func (f *fakeDatabaseSettingsStore) Get(string) (databaseconfig.PublicSetting, bool) {
|
|
||||||
return databaseconfig.PublicSetting{}, false
|
|
||||||
}
|
|
||||||
func (f *fakeDatabaseSettingsStore) Upsert(_ context.Context, setting databaseconfig.Setting) (databaseconfig.PublicSetting, bool, error) {
|
|
||||||
f.setting = setting
|
|
||||||
return databaseconfig.PublicSetting{
|
|
||||||
RSCode: setting.RSCode, Host: setting.Host, Port: setting.Port,
|
|
||||||
Database: setting.Database, Username: setting.Username, HasPassword: setting.Password != "",
|
|
||||||
}, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStoreDatabaseSettingDoesNotExposePassword(t *testing.T) {
|
|
||||||
store := &fakeDatabaseSettingsStore{}
|
|
||||||
handler := NewDatabaseSettingsHandler(store)
|
|
||||||
body := `{"kode_rs":"RS_BEKASI","nama":"RS Bekasi","host":"10.0.0.2","port":"3306","database":"his","username":"cpone","password":"top-secret"}`
|
|
||||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/database-settings", strings.NewReader(body))
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
handler.Store(recorder, request)
|
|
||||||
|
|
||||||
if recorder.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
|
||||||
}
|
|
||||||
if store.setting.Password != "top-secret" {
|
|
||||||
t.Fatal("password was not passed to store")
|
|
||||||
}
|
|
||||||
if strings.Contains(recorder.Body.String(), "top-secret") || strings.Contains(recorder.Body.String(), `"password"`) {
|
|
||||||
t.Fatalf("response exposes password: %s", recorder.Body.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(recorder.Body.String(), `"has_password":true`) {
|
|
||||||
t.Fatalf("response missing has_password: %s", recorder.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStoreDatabaseSettingValidation(t *testing.T) {
|
|
||||||
handler := NewDatabaseSettingsHandler(&fakeDatabaseSettingsStore{})
|
|
||||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/database-settings", strings.NewReader(`{"kode_rs":"invalid code"}`))
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
handler.Store(recorder, request)
|
|
||||||
if recorder.Code != http.StatusUnprocessableEntity {
|
|
||||||
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -65,6 +65,7 @@ type Handler struct {
|
|||||||
periodeTarifRepo PeriodeTarifRepository
|
periodeTarifRepo PeriodeTarifRepository
|
||||||
periodeTarifDetailRepo PeriodeTarifDetailRepository
|
periodeTarifDetailRepo PeriodeTarifDetailRepository
|
||||||
perusahaanRepo PerusahaanRepository
|
perusahaanRepo PerusahaanRepository
|
||||||
|
hospitalCode string
|
||||||
}
|
}
|
||||||
|
|
||||||
type CPONERepository interface {
|
type CPONERepository interface {
|
||||||
@@ -87,7 +88,9 @@ func NewHandler(repo CPONERepository) Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDynamicHandler() Handler { return Handler{} }
|
func NewDynamicHandler(hospitalCode string) Handler {
|
||||||
|
return Handler{hospitalCode: strings.ToUpper(strings.TrimSpace(hospitalCode))}
|
||||||
|
}
|
||||||
|
|
||||||
func (h Handler) layananRepository(ctx context.Context) LayananRepository {
|
func (h Handler) layananRepository(ctx context.Context) LayananRepository {
|
||||||
if repo, ok := selectedRepository(ctx); ok {
|
if repo, ok := selectedRepository(ctx); ok {
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"primaya-api/cpone-middleware/internal/databaseconfig"
|
|
||||||
)
|
|
||||||
|
|
||||||
type HospitalSummary struct {
|
|
||||||
RSCode string `json:"kode_rs"`
|
|
||||||
Name string `json:"nama"`
|
|
||||||
Status bool `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h DatabaseSettingsHandler) ListHospitals(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
settings := h.store.List()
|
|
||||||
hospitals := make([]HospitalSummary, 0, len(settings))
|
|
||||||
for _, setting := range settings {
|
|
||||||
if !setting.Active {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
hospitals = append(hospitals, hospitalSummary(setting))
|
|
||||||
}
|
|
||||||
WriteJSON(w, http.StatusOK, Response{Success: true, Message: "ok", Data: hospitals})
|
|
||||||
}
|
|
||||||
|
|
||||||
func hospitalSummary(setting databaseconfig.PublicSetting) HospitalSummary {
|
|
||||||
return HospitalSummary{RSCode: setting.RSCode, Name: setting.Name, Status: setting.Active}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"primaya-api/cpone-middleware/internal/databaseconfig"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeHospitalStore struct {
|
|
||||||
fakeDatabaseSettingsStore
|
|
||||||
settings []databaseconfig.PublicSetting
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeHospitalStore) List() []databaseconfig.PublicSetting { return f.settings }
|
|
||||||
|
|
||||||
func TestListHospitalsOnlyReturnsActiveWithoutDatabaseCredentials(t *testing.T) {
|
|
||||||
store := &fakeHospitalStore{settings: []databaseconfig.PublicSetting{
|
|
||||||
{RSCode: "AWALBROS_DEV", Name: "RS Dev Awalbros", Host: "secret-host", Username: "secret-user", Active: true},
|
|
||||||
{RSCode: "INACTIVE_RS", Name: "RS Tidak Aktif", Host: "inactive-host", Active: false},
|
|
||||||
}}
|
|
||||||
handler := NewDatabaseSettingsHandler(store)
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
handler.ListHospitals(recorder, httptest.NewRequest(http.MethodGet, "/api/cpone/hospitals", nil))
|
|
||||||
|
|
||||||
if recorder.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
||||||
}
|
|
||||||
for _, expected := range []string{`"kode_rs":"AWALBROS_DEV"`, `"nama":"RS Dev Awalbros"`, `"status":true`} {
|
|
||||||
if !strings.Contains(recorder.Body.String(), expected) {
|
|
||||||
t.Fatalf("response missing %s: %s", expected, recorder.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, forbidden := range []string{"INACTIVE_RS", "RS Tidak Aktif", "secret-host", "secret-user", `"host"`, `"username"`, `"password"`} {
|
|
||||||
if strings.Contains(recorder.Body.String(), forbidden) {
|
|
||||||
t.Fatalf("response contains %q: %s", forbidden, recorder.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListHospitalsReturnsEmptyArray(t *testing.T) {
|
|
||||||
handler := NewDatabaseSettingsHandler(&fakeHospitalStore{})
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
handler.ListHospitals(recorder, httptest.NewRequest(http.MethodGet, "/api/cpone/hospitals", nil))
|
|
||||||
if recorder.Body.String() != `{"success":true,"message":"ok","data":[]}`+"\n" {
|
|
||||||
t.Fatalf("body=%s", recorder.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -32,6 +32,7 @@ func (h Handler) StoreMedrec(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeMedrecResponse(w, http.StatusUnprocessableEntity, false, "validation_error", message, nil)
|
writeMedrecResponse(w, http.StatusUnprocessableEntity, false, "validation_error", message, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
params.HospitalCode = h.hospitalCode
|
||||||
|
|
||||||
outcome, err := h.patientRepository(r.Context()).StoreMedrec(r.Context(), params)
|
outcome, err := h.patientRepository(r.Context()).StoreMedrec(r.Context(), params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -48,10 +49,6 @@ func (h Handler) StoreMedrec(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateMedrec(payload map[string]any) (repository.MedrecParams, string) {
|
func validateMedrec(payload map[string]any) (repository.MedrecParams, string) {
|
||||||
rs, message := requiredString(payload, "rs", 20, "Kode RS wajib diisi")
|
|
||||||
if message != "" {
|
|
||||||
return repository.MedrecParams{}, message
|
|
||||||
}
|
|
||||||
name, message := requiredString(payload, "nama", 255, "Nama pasien wajib diisi")
|
name, message := requiredString(payload, "nama", 255, "Nama pasien wajib diisi")
|
||||||
if message != "" {
|
if message != "" {
|
||||||
return repository.MedrecParams{}, message
|
return repository.MedrecParams{}, message
|
||||||
@@ -191,7 +188,6 @@ func validateMedrec(payload map[string]any) (repository.MedrecParams, string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return repository.MedrecParams{
|
return repository.MedrecParams{
|
||||||
HospitalCode: normalizeText(rs),
|
|
||||||
Name: normalizeName(name),
|
Name: normalizeName(name),
|
||||||
Gender: normalizeText(gender),
|
Gender: normalizeText(gender),
|
||||||
DateOfBirth: birthDate,
|
DateOfBirth: birthDate,
|
||||||
|
|||||||
@@ -48,9 +48,8 @@ func TestStoreMedrecValidPayloadAndCreatedResponseParity(t *testing.T) {
|
|||||||
IsNewPatient: true,
|
IsNewPatient: true,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
handler := Handler{patientRepo: repo}
|
handler := Handler{patientRepo: repo, hospitalCode: "PRIMAYA"}
|
||||||
body := `{
|
body := `{
|
||||||
"rs":" primaya ",
|
|
||||||
"nama":" Budi Santos ",
|
"nama":" Budi Santos ",
|
||||||
"jenis_kelamin":"l",
|
"jenis_kelamin":"l",
|
||||||
"tgl_lahir":"1990-01-15",
|
"tgl_lahir":"1990-01-15",
|
||||||
@@ -107,7 +106,6 @@ func TestStoreMedrecValidPayloadAndCreatedResponseParity(t *testing.T) {
|
|||||||
|
|
||||||
func TestStoreMedrecValidationParity(t *testing.T) {
|
func TestStoreMedrecValidationParity(t *testing.T) {
|
||||||
baseline := map[string]any{
|
baseline := map[string]any{
|
||||||
"rs": "PRIMAYA",
|
|
||||||
"nama": "BUDI SANTOS",
|
"nama": "BUDI SANTOS",
|
||||||
"jenis_kelamin": "L",
|
"jenis_kelamin": "L",
|
||||||
"tgl_lahir": "1990-01-15",
|
"tgl_lahir": "1990-01-15",
|
||||||
@@ -122,7 +120,6 @@ func TestStoreMedrecValidationParity(t *testing.T) {
|
|||||||
mutate func(map[string]any)
|
mutate func(map[string]any)
|
||||||
message string
|
message string
|
||||||
}{
|
}{
|
||||||
{name: "missing rs", mutate: func(p map[string]any) { delete(p, "rs") }, message: "Kode RS wajib diisi"},
|
|
||||||
{name: "missing name", mutate: func(p map[string]any) { delete(p, "nama") }, message: "Nama pasien wajib diisi"},
|
{name: "missing name", mutate: func(p map[string]any) { delete(p, "nama") }, message: "Nama pasien wajib diisi"},
|
||||||
{name: "invalid gender", mutate: func(p map[string]any) { p["jenis_kelamin"] = "M" }, message: "Jenis kelamin harus L atau P"},
|
{name: "invalid gender", mutate: func(p map[string]any) { p["jenis_kelamin"] = "M" }, message: "Jenis kelamin harus L atau P"},
|
||||||
{name: "invalid date format", mutate: func(p map[string]any) { p["tgl_lahir"] = "15-01-1990" }, message: "Tanggal lahir harus format Y-m-d"},
|
{name: "invalid date format", mutate: func(p map[string]any) { p["tgl_lahir"] = "15-01-1990" }, message: "Tanggal lahir harus format Y-m-d"},
|
||||||
@@ -131,7 +128,6 @@ func TestStoreMedrecValidationParity(t *testing.T) {
|
|||||||
{name: "invalid ktp", mutate: func(p map[string]any) { p["nomer_sosial"] = "123" }, message: "NIK/KTP harus 16 digit angka"},
|
{name: "invalid ktp", mutate: func(p map[string]any) { p["nomer_sosial"] = "123" }, message: "NIK/KTP harus 16 digit angka"},
|
||||||
{name: "missing phone", mutate: func(p map[string]any) { delete(p, "telepon") }, message: "Telepon atau ponsel wajib diisi"},
|
{name: "missing phone", mutate: func(p map[string]any) { delete(p, "telepon") }, message: "Telepon atau ponsel wajib diisi"},
|
||||||
{name: "invalid email", mutate: func(p map[string]any) { p["email"] = "invalid" }, message: "The email must be a valid email address."},
|
{name: "invalid email", mutate: func(p map[string]any) { p["email"] = "invalid" }, message: "The email must be a valid email address."},
|
||||||
{name: "long hospital code", mutate: func(p map[string]any) { p["rs"] = strings.Repeat("X", 21) }, message: "The rs must not be greater than 20 characters."},
|
|
||||||
{name: "long dependant id", mutate: func(p map[string]any) { p["dependant_id"] = "D0001" }, message: "The dependant id must not be greater than 4 characters."},
|
{name: "long dependant id", mutate: func(p map[string]any) { p["dependant_id"] = "D0001" }, message: "The dependant id must not be greater than 4 characters."},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +168,7 @@ func TestStoreMedrecNikFallbackAndOutcomeForwarding(t *testing.T) {
|
|||||||
Data: data,
|
Data: data,
|
||||||
}}
|
}}
|
||||||
handler := Handler{patientRepo: repo}
|
handler := Handler{patientRepo: repo}
|
||||||
body := `{"rs":"PRIMAYA","nama":"BUDI","jenis_kelamin":"P","tgl_lahir":"1990-01-15","nik":"3174011501900001","ponsel":"08123"}`
|
body := `{"nama":"BUDI","jenis_kelamin":"P","tgl_lahir":"1990-01-15","nik":"3174011501900001","ponsel":"08123"}`
|
||||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/medrec", strings.NewReader(body))
|
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/medrec", strings.NewReader(body))
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
handler.StoreMedrec(recorder, request)
|
handler.StoreMedrec(recorder, request)
|
||||||
@@ -184,7 +180,7 @@ func TestStoreMedrecNikFallbackAndOutcomeForwarding(t *testing.T) {
|
|||||||
|
|
||||||
func TestStoreMedrecRepositoryErrorParity(t *testing.T) {
|
func TestStoreMedrecRepositoryErrorParity(t *testing.T) {
|
||||||
handler := Handler{patientRepo: &fakePatientRepository{err: errors.New("database unavailable")}}
|
handler := Handler{patientRepo: &fakePatientRepository{err: errors.New("database unavailable")}}
|
||||||
body := `{"rs":"PRIMAYA","nama":"BUDI","jenis_kelamin":"L","tgl_lahir":"1990-01-15","nomer_sosial":"3174011501900001","telepon":"08123"}`
|
body := `{"nama":"BUDI","jenis_kelamin":"L","tgl_lahir":"1990-01-15","nomer_sosial":"3174011501900001","telepon":"08123"}`
|
||||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/medrec", strings.NewReader(body))
|
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/medrec", strings.NewReader(body))
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
handler.StoreMedrec(recorder, request)
|
handler.StoreMedrec(recorder, request)
|
||||||
|
|||||||
@@ -46,13 +46,13 @@ func SelectDatabase(resolver DatabaseResolver, next http.Handler) http.Handler {
|
|||||||
|
|
||||||
repo, err := resolver.Resolve(r.Context(), code)
|
repo, err := resolver.Resolve(r.Context(), code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, databaseconfig.ErrSettingNotFound) {
|
if errors.Is(err, databaseconfig.ErrRSCodeMismatch) {
|
||||||
WriteJSON(w, http.StatusNotFound, Response{Success: false,
|
WriteJSON(w, http.StatusForbidden, Response{Success: false,
|
||||||
Message: "Setting database untuk kode RS " + code + " tidak ditemukan", Data: nil})
|
Message: "X-RS-Code tidak sesuai dengan instance CPONE", Data: nil})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
WriteJSON(w, http.StatusServiceUnavailable, Response{Success: false,
|
WriteJSON(w, http.StatusServiceUnavailable, Response{Success: false,
|
||||||
Message: "Database untuk kode RS " + code + " tidak dapat diakses", Data: nil})
|
Message: "Database HIS tidak dapat diakses", Data: nil})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("X-RS-Code", code)
|
w.Header().Set("X-RS-Code", code)
|
||||||
|
|||||||
@@ -66,14 +66,14 @@ func TestSelectDatabaseRequiresRSCode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSelectDatabaseUnknownCode(t *testing.T) {
|
func TestSelectDatabaseRejectsWrongInstanceCode(t *testing.T) {
|
||||||
resolver := &fakeDatabaseResolver{err: databaseconfig.ErrSettingNotFound}
|
resolver := &fakeDatabaseResolver{err: databaseconfig.ErrRSCodeMismatch}
|
||||||
request := httptest.NewRequest(http.MethodGet, "/resource?kode_rs=unknown", nil)
|
request := httptest.NewRequest(http.MethodGet, "/resource?kode_rs=unknown", nil)
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
SelectDatabase(resolver, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
SelectDatabase(resolver, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||||
t.Fatal("next handler should not be called")
|
t.Fatal("next handler should not be called")
|
||||||
})).ServeHTTP(recorder, request)
|
})).ServeHTTP(recorder, request)
|
||||||
if recorder.Code != http.StatusNotFound {
|
if recorder.Code != http.StatusForbidden {
|
||||||
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user