From 9afd5b792cb1eac5b1798477a43e35df4e369b32 Mon Sep 17 00:00:00 2001 From: Tubagus Fajri Date: Fri, 4 Sep 2026 08:56:29 +0700 Subject: [PATCH] refactor: use static HIS database per instance --- cmd/server/main.go | 27 +- internal/config/config.go | 57 +-- internal/databaseconfig/registry.go | 443 ---------------------- internal/databaseconfig/registry_test.go | 63 --- internal/databaseconfig/static.go | 105 +++++ internal/databaseconfig/static_test.go | 17 + internal/http/database_settings.go | 72 ---- internal/http/database_settings_test.go | 59 --- internal/http/handler.go | 5 +- internal/http/hospitals.go | 29 -- internal/http/hospitals_test.go | 50 --- internal/http/medrec.go | 6 +- internal/http/medrec_test.go | 10 +- internal/http/middleware.go | 8 +- internal/http/middleware_database_test.go | 6 +- 15 files changed, 174 insertions(+), 783 deletions(-) delete mode 100644 internal/databaseconfig/registry.go delete mode 100644 internal/databaseconfig/registry_test.go create mode 100644 internal/databaseconfig/static.go create mode 100644 internal/databaseconfig/static_test.go delete mode 100644 internal/http/database_settings.go delete mode 100644 internal/http/database_settings_test.go delete mode 100644 internal/http/hospitals.go delete mode 100644 internal/http/hospitals_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index db078d5..cfa1d95 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -22,29 +22,22 @@ func main() { } bootstrapCtx, bootstrapCancel := context.WithTimeout(context.Background(), 20*time.Second) - registry, err := databaseconfig.NewRegistry(bootstrapCtx, databaseconfig.Setting{ - Host: cfg.ManagementDBHost, Port: cfg.ManagementDBPort, Database: cfg.ManagementDBDatabase, - Username: cfg.ManagementDBUsername, Password: cfg.ManagementDBPassword, + resolver, err := databaseconfig.NewStaticResolver(bootstrapCtx, cfg.RSCode, databaseconfig.Setting{ + Host: cfg.HISDBHost, Port: cfg.HISDBPort, Database: cfg.HISDBDatabase, + Username: cfg.HISDBUsername, Password: cfg.HISDBPassword, }, databaseconfig.PoolConfig{ MaxOpenConns: cfg.DBMaxOpenConns, MaxIdleConns: cfg.DBMaxIdleConns, ConnMaxLifetime: cfg.DBConnMaxLifetime, - }, cfg.DBCredentialSecret) + }) bootstrapCancel() 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() - settingsHandler := apphttp.NewDatabaseSettingsHandler(registry) + handler := apphttp.NewDynamicHandler(cfg.RSCode) protected := func(handler http.Handler) http.Handler { - return apphttp.BearerAuth(cfg.BearerToken, apphttp.SelectDatabase(registry, 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) + return apphttp.BearerAuth(cfg.BearerToken, apphttp.SelectDatabase(resolver, handler)) } 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}/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/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) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) diff --git a/internal/config/config.go b/internal/config/config.go index 22b2a82..1c645e4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,19 +10,18 @@ import ( ) type Config struct { - AppHost string - AppPort string - BearerToken string - DBSettingsToken string - DBMaxOpenConns int - DBMaxIdleConns int - DBConnMaxLifetime time.Duration - ManagementDBHost string - ManagementDBPort string - ManagementDBDatabase string - ManagementDBUsername string - ManagementDBPassword string - DBCredentialSecret string + AppHost string + AppPort string + RSCode string + BearerToken string + DBMaxOpenConns int + DBMaxIdleConns int + DBConnMaxLifetime time.Duration + HISDBHost string + HISDBPort string + HISDBDatabase string + HISDBUsername string + HISDBPassword string } func Load(path string) (Config, error) { @@ -31,26 +30,28 @@ func Load(path string) (Config, error) { } cfg := Config{ - AppHost: env("APP_HOST", "0.0.0.0"), - AppPort: env("APP_PORT", "8080"), - BearerToken: env("CPONE_BEARER_TOKEN", ""), - DBSettingsToken: env("CPONE_DATABASE_SETTINGS_TOKEN", env("CPONE_BEARER_TOKEN", "")), - DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 25), - DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 25), - DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MINUTES", 5)) * time.Minute, - ManagementDBHost: env("CPONE_DB_HOST", "127.0.0.1"), - ManagementDBPort: env("CPONE_DB_PORT", "3306"), - ManagementDBDatabase: env("CPONE_DB_DATABASE", "cpone_middleware"), - ManagementDBUsername: env("CPONE_DB_USERNAME", "root"), - ManagementDBPassword: env("CPONE_DB_PASSWORD", ""), - DBCredentialSecret: env("CPONE_DATABASE_CREDENTIAL_KEY", env("CPONE_DATABASE_SETTINGS_TOKEN", env("CPONE_BEARER_TOKEN", ""))), + AppHost: env("APP_HOST", "0.0.0.0"), + AppPort: env("APP_PORT", "8080"), + RSCode: env("CPONE_RS_CODE", ""), + BearerToken: env("CPONE_BEARER_TOKEN", ""), + DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 25), + DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 25), + DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MINUTES", 5)) * time.Minute, + HISDBHost: env("CPONE_DB_HOST", "127.0.0.1"), + HISDBPort: env("CPONE_DB_PORT", "3306"), + HISDBDatabase: env("CPONE_DB_DATABASE", ""), + HISDBUsername: env("CPONE_DB_USERNAME", ""), + HISDBPassword: env("CPONE_DB_PASSWORD", ""), } + if cfg.RSCode == "" { + return Config{}, fmt.Errorf("CPONE_RS_CODE wajib diisi") + } if cfg.BearerToken == "" { return Config{}, fmt.Errorf("CPONE_BEARER_TOKEN wajib diisi") } - if cfg.ManagementDBDatabase == "" { - return Config{}, fmt.Errorf("CPONE_DB_DATABASE wajib diisi") + if cfg.HISDBDatabase == "" || cfg.HISDBUsername == "" { + return Config{}, fmt.Errorf("CPONE_DB_DATABASE dan CPONE_DB_USERNAME wajib diisi") } return cfg, nil diff --git a/internal/databaseconfig/registry.go b/internal/databaseconfig/registry.go deleted file mode 100644 index 584274e..0000000 --- a/internal/databaseconfig/registry.go +++ /dev/null @@ -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() -} diff --git a/internal/databaseconfig/registry_test.go b/internal/databaseconfig/registry_test.go deleted file mode 100644 index f82198b..0000000 --- a/internal/databaseconfig/registry_test.go +++ /dev/null @@ -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") - } - }) - } -} diff --git a/internal/databaseconfig/static.go b/internal/databaseconfig/static.go new file mode 100644 index 0000000..aaa93d0 --- /dev/null +++ b/internal/databaseconfig/static.go @@ -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() +} diff --git a/internal/databaseconfig/static_test.go b/internal/databaseconfig/static_test.go new file mode 100644 index 0000000..7046ce0 --- /dev/null +++ b/internal/databaseconfig/static_test.go @@ -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) + } +} diff --git a/internal/http/database_settings.go b/internal/http/database_settings.go deleted file mode 100644 index e966cdd..0000000 --- a/internal/http/database_settings.go +++ /dev/null @@ -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}) -} diff --git a/internal/http/database_settings_test.go b/internal/http/database_settings_test.go deleted file mode 100644 index 54741ca..0000000 --- a/internal/http/database_settings_test.go +++ /dev/null @@ -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()) - } -} diff --git a/internal/http/handler.go b/internal/http/handler.go index ec56ec6..792f067 100644 --- a/internal/http/handler.go +++ b/internal/http/handler.go @@ -65,6 +65,7 @@ type Handler struct { periodeTarifRepo PeriodeTarifRepository periodeTarifDetailRepo PeriodeTarifDetailRepository perusahaanRepo PerusahaanRepository + hospitalCode string } 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 { if repo, ok := selectedRepository(ctx); ok { diff --git a/internal/http/hospitals.go b/internal/http/hospitals.go deleted file mode 100644 index 0a78d4a..0000000 --- a/internal/http/hospitals.go +++ /dev/null @@ -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} -} diff --git a/internal/http/hospitals_test.go b/internal/http/hospitals_test.go deleted file mode 100644 index ad183bc..0000000 --- a/internal/http/hospitals_test.go +++ /dev/null @@ -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()) - } -} diff --git a/internal/http/medrec.go b/internal/http/medrec.go index dd4b159..f19f851 100644 --- a/internal/http/medrec.go +++ b/internal/http/medrec.go @@ -32,6 +32,7 @@ func (h Handler) StoreMedrec(w http.ResponseWriter, r *http.Request) { writeMedrecResponse(w, http.StatusUnprocessableEntity, false, "validation_error", message, nil) return } + params.HospitalCode = h.hospitalCode outcome, err := h.patientRepository(r.Context()).StoreMedrec(r.Context(), params) 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) { - 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") if message != "" { return repository.MedrecParams{}, message @@ -191,7 +188,6 @@ func validateMedrec(payload map[string]any) (repository.MedrecParams, string) { } return repository.MedrecParams{ - HospitalCode: normalizeText(rs), Name: normalizeName(name), Gender: normalizeText(gender), DateOfBirth: birthDate, diff --git a/internal/http/medrec_test.go b/internal/http/medrec_test.go index 72f1f9e..7330c10 100644 --- a/internal/http/medrec_test.go +++ b/internal/http/medrec_test.go @@ -48,9 +48,8 @@ func TestStoreMedrecValidPayloadAndCreatedResponseParity(t *testing.T) { IsNewPatient: true, }, }} - handler := Handler{patientRepo: repo} + handler := Handler{patientRepo: repo, hospitalCode: "PRIMAYA"} body := `{ - "rs":" primaya ", "nama":" Budi Santos ", "jenis_kelamin":"l", "tgl_lahir":"1990-01-15", @@ -107,7 +106,6 @@ func TestStoreMedrecValidPayloadAndCreatedResponseParity(t *testing.T) { func TestStoreMedrecValidationParity(t *testing.T) { baseline := map[string]any{ - "rs": "PRIMAYA", "nama": "BUDI SANTOS", "jenis_kelamin": "L", "tgl_lahir": "1990-01-15", @@ -122,7 +120,6 @@ func TestStoreMedrecValidationParity(t *testing.T) { mutate func(map[string]any) 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: "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"}, @@ -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: "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: "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."}, } @@ -172,7 +168,7 @@ func TestStoreMedrecNikFallbackAndOutcomeForwarding(t *testing.T) { Data: data, }} 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)) recorder := httptest.NewRecorder() handler.StoreMedrec(recorder, request) @@ -184,7 +180,7 @@ func TestStoreMedrecNikFallbackAndOutcomeForwarding(t *testing.T) { func TestStoreMedrecRepositoryErrorParity(t *testing.T) { 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)) recorder := httptest.NewRecorder() handler.StoreMedrec(recorder, request) diff --git a/internal/http/middleware.go b/internal/http/middleware.go index 6099930..fad0ac8 100644 --- a/internal/http/middleware.go +++ b/internal/http/middleware.go @@ -46,13 +46,13 @@ func SelectDatabase(resolver DatabaseResolver, next http.Handler) http.Handler { repo, err := resolver.Resolve(r.Context(), code) if err != nil { - if errors.Is(err, databaseconfig.ErrSettingNotFound) { - WriteJSON(w, http.StatusNotFound, Response{Success: false, - Message: "Setting database untuk kode RS " + code + " tidak ditemukan", Data: nil}) + if errors.Is(err, databaseconfig.ErrRSCodeMismatch) { + WriteJSON(w, http.StatusForbidden, Response{Success: false, + Message: "X-RS-Code tidak sesuai dengan instance CPONE", Data: nil}) return } 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 } w.Header().Set("X-RS-Code", code) diff --git a/internal/http/middleware_database_test.go b/internal/http/middleware_database_test.go index 9f5d447..5d82652 100644 --- a/internal/http/middleware_database_test.go +++ b/internal/http/middleware_database_test.go @@ -66,14 +66,14 @@ func TestSelectDatabaseRequiresRSCode(t *testing.T) { } } -func TestSelectDatabaseUnknownCode(t *testing.T) { - resolver := &fakeDatabaseResolver{err: databaseconfig.ErrSettingNotFound} +func TestSelectDatabaseRejectsWrongInstanceCode(t *testing.T) { + resolver := &fakeDatabaseResolver{err: databaseconfig.ErrRSCodeMismatch} request := httptest.NewRequest(http.MethodGet, "/resource?kode_rs=unknown", nil) recorder := httptest.NewRecorder() SelectDatabase(resolver, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("next handler should not be called") })).ServeHTTP(recorder, request) - if recorder.Code != http.StatusNotFound { + if recorder.Code != http.StatusForbidden { t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String()) }