Ograničenje konekcija BE emulatora, sopstveni kontekst fiskalizacije nezavisan od prekida zahteva, validacija porta na setup stranici, serverska optimizacija kratke pretrage bez eval-a, testovi

This commit is contained in:
2026-07-13 15:10:02 +02:00
parent bc60288a0d
commit 9cf4e66f00
24 changed files with 619 additions and 40 deletions
+9
View File
@@ -262,6 +262,11 @@ func (k *Kartica) cmdVerifyPin(pin string) map[string]any {
return map[string]any{"status": "ok"}
}
// cmdResetAudit namerno NE zahteva pinUnesen: koristi ga isključivo admin panel
// (BeResetAudit, već iza "podesavanja.izmeni" RBAC provere) preko sopstvene TCP
// konekcije koja nikad ne zove verify_pin — zahtevanje PIN-a ovde bi trajno
// pokvarilo dugme "Resetuj audit" u Podešavanjima. cmdSign ostaje PIN-zaštićen
// jer njega poziva eksterni fiskalni klijent ("Fisk") koji prethodno zove verify_pin.
func (k *Kartica) cmdResetAudit() map[string]any {
k.mu.Lock()
defer k.mu.Unlock()
@@ -274,6 +279,10 @@ func (k *Kartica) cmdSign(invoiceType, transactionType string, totalAmount float
k.mu.Lock()
defer k.mu.Unlock()
if !k.pinUnesen {
return map[string]any{"status": "error", "code": "2101", "message": "PIN nije verifikovan"}
}
// blokada limite
if k.unreadAmount >= k.Limit {
return map[string]any{
+126
View File
@@ -0,0 +1,126 @@
package be
import "testing"
func novaTestnaKartica() *Kartica {
return &Kartica{
PIN: "1234",
Limit: 500000,
counters: map[string]int{
"pp": 0, "pr": 0, "ap": 0, "ar": 0,
"kp": 0, "kr": 0, "op": 0, "or": 0,
"rp": 0, "rr": 0,
},
}
}
func TestCmdSignBezPinaOdbija(t *testing.T) {
k := novaTestnaKartica()
resp := k.cmdSign("Normal", "Sale", 100)
if resp["status"] != "error" {
t.Fatalf("cmdSign bez verifikovanog PIN-a mora vratiti status=error, dobijeno: %v", resp)
}
if resp["code"] != "2101" {
t.Errorf("code = %v, očekivano 2101", resp["code"])
}
if k.totalCounter != 0 {
t.Errorf("totalCounter se povećao iako PIN nije verifikovan: %d", k.totalCounter)
}
}
func TestCmdSignPogresanPinOdbija(t *testing.T) {
k := novaTestnaKartica()
if resp := k.cmdVerifyPin("0000"); resp["status"] != "error" {
t.Fatalf("cmdVerifyPin sa pogrešnim PIN-om mora vratiti error, dobijeno: %v", resp)
}
if k.pinUnesen {
t.Fatal("pinUnesen ne sme biti true posle pogrešnog PIN-a")
}
if resp := k.cmdSign("Normal", "Sale", 100); resp["status"] != "error" {
t.Fatalf("cmdSign posle pogrešnog PIN-a mora ostati blokiran, dobijeno: %v", resp)
}
}
func TestCmdSignPoslePravogPinaUspeva(t *testing.T) {
k := novaTestnaKartica()
if resp := k.cmdVerifyPin("1234"); resp["status"] != "ok" {
t.Fatalf("cmdVerifyPin sa ispravnim PIN-om mora vratiti ok, dobijeno: %v", resp)
}
resp := k.cmdSign("Normal", "Sale", 100)
if resp["status"] != "ok" {
t.Fatalf("cmdSign posle ispravnog PIN-a mora uspeti, dobijeno: %v", resp)
}
if k.totalCounter != 1 {
t.Errorf("totalCounter = %d, očekivano 1", k.totalCounter)
}
if k.unreadAmount != 100 {
t.Errorf("unreadAmount = %v, očekivano 100", k.unreadAmount)
}
}
func TestCmdSignBlokiraNaLimitu(t *testing.T) {
k := novaTestnaKartica()
k.pinUnesen = true
k.unreadAmount = k.Limit
resp := k.cmdSign("Normal", "Sale", 1)
if resp["status"] != "blocked" {
t.Fatalf("cmdSign na limitu mora vratiti status=blocked, dobijeno: %v", resp)
}
}
func TestCmdSignRefundSmanjujeUnreadAmount(t *testing.T) {
k := novaTestnaKartica()
k.pinUnesen = true
k.unreadAmount = 500
k.cmdSign("Normal", "Refund", 200)
if k.unreadAmount != 300 {
t.Errorf("unreadAmount posle refunda = %v, očekivano 300", k.unreadAmount)
}
}
func TestCmdSignRefundNeIdeUNegativu(t *testing.T) {
k := novaTestnaKartica()
k.pinUnesen = true
k.unreadAmount = 50
k.cmdSign("Normal", "Refund", 200)
if k.unreadAmount != 0 {
t.Errorf("unreadAmount ne sme biti negativan, dobijeno %v", k.unreadAmount)
}
}
func TestCmdResetAuditRadiBezPina(t *testing.T) {
// namerno: admin panel (BeResetAudit) nikad ne zove verify_pin, v. komentar
// uz cmdResetAudit u kartica.go — reset ne sme zahtevati PIN.
k := novaTestnaKartica()
k.unreadAmount = 12345
resp := k.cmdResetAudit()
if resp["status"] != "ok" {
t.Fatalf("cmdResetAudit mora uspeti i bez PIN-a, dobijeno: %v", resp)
}
if k.unreadAmount != 0 {
t.Errorf("unreadAmount posle reset_audit = %v, očekivano 0", k.unreadAmount)
}
}
func TestCmdVerifyPinKonstantnoVreme(t *testing.T) {
k := novaTestnaKartica()
if resp := k.cmdVerifyPin("1234"); resp["status"] != "ok" {
t.Fatalf("očekivan uspeh sa ispravnim PIN-om, dobijeno: %v", resp)
}
if !k.pinUnesen {
t.Fatal("pinUnesen mora biti true posle ispravnog PIN-a")
}
}
func TestCmdStatusPrijavljujePinRequired(t *testing.T) {
k := novaTestnaKartica()
resp := k.cmdStatus()
if resp["pin_required"] != true {
t.Fatalf("pin_required mora biti true pre verifikacije, dobijeno: %v", resp)
}
k.cmdVerifyPin("1234")
resp = k.cmdStatus()
if resp["pin_required"] != false {
t.Fatalf("pin_required mora biti false posle verifikacije, dobijeno: %v", resp)
}
}
+30 -2
View File
@@ -8,8 +8,19 @@ import (
"log/slog"
"net"
"os"
"time"
)
// maxKonekcija ograničava broj istovremenih TCP konekcija — bez ovoga bi klijent
// koji samo otvori konekciju i ništa ne pošalje mogao da drži neograničen broj
// gorutina/file descriptor-a (v. BUG.md #36).
const maxKonekcija = 32
// citanjeTimeout je rok po redu (komandi) koji se osvežava pre svakog čitanja —
// klijent koji ćuti duže od ovoga se prekida umesto da drži konekciju zauvek.
// var (ne const) da bi testovi mogli da ga privremeno skrate.
var citanjeTimeout = 30 * time.Second
// JeUkljucen vraća true ako emulator treba da se pokrene.
// Prioritet: env var BE_ENABLED > DB podesavanje be_enabled > podrazumevano true.
func JeUkljucen(db *sql.DB) bool {
@@ -35,6 +46,7 @@ func Pokreni(db *sql.DB) {
slog.Info("kartica emulator sluša", "addr", addr)
k := novaKartica(db)
sem := make(chan struct{}, maxKonekcija)
for {
conn, err := ln.Accept()
@@ -42,7 +54,16 @@ func Pokreni(db *sql.DB) {
slog.Warn("be: greška pri Accept()", "error", err)
continue
}
go obradiKonekciju(conn, k)
select {
case sem <- struct{}{}:
go func() {
defer func() { <-sem }()
obradiKonekciju(conn, k)
}()
default:
slog.Warn("be: dostignut limit konekcija, odbijam klijenta", "limit", maxKonekcija)
conn.Close()
}
}
}
@@ -50,7 +71,14 @@ func obradiKonekciju(conn net.Conn, k *Kartica) {
defer conn.Close()
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
for {
// SetDeadline (ne samo SetReadDeadline) da rok pokrije i odgovori() Write
// koji sledi posle čitanja — klijent koji šalje komande a nikad ne čita
// odgovore bi inače mogao da blokira Write zauvek i iscrpi semafor konekcija.
conn.SetDeadline(time.Now().Add(citanjeTimeout))
if !scanner.Scan() {
return
}
line := scanner.Bytes()
if len(line) == 0 {
continue
+87
View File
@@ -0,0 +1,87 @@
package be
import (
"bufio"
"encoding/json"
"net"
"testing"
"time"
)
// TestObradiKonekcijaZatvaraNakonTisine proverava ispravku BUG.md #36 — konekcija
// koja ništa ne pošalje mora biti zatvorena posle citanjeTimeout-a, ne držana
// zauvek.
func TestObradiKonekcijaZatvaraNakonTisine(t *testing.T) {
stariTimeout := citanjeTimeout
citanjeTimeout = 100 * time.Millisecond
defer func() { citanjeTimeout = stariTimeout }()
klijentska, serverska := net.Pipe()
defer klijentska.Close()
gotovo := make(chan struct{})
go func() {
obradiKonekciju(serverska, novaTestnaKartica())
close(gotovo)
}()
select {
case <-gotovo:
// očekivano: server je zatvorio konekciju posle isteka citanjeTimeout-a
case <-time.After(2 * time.Second):
t.Fatal("obradiKonekciju nije prekinuta ni posle 2s tišine klijenta — deadline ne radi")
}
}
// TestObradiKonekcijaOdgovaraNaKomandu proverava da server i dalje ispravno
// odgovara na komandu pre nego što istekne deadline (da fix #36 ne pokvari
// normalan tok status → verify_pin → sign preko realnog TCP-a).
func TestObradiKonekcijaOdgovaraNaKomandu(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("ne mogu da otvorim listener: %v", err)
}
defer ln.Close()
k := novaTestnaKartica()
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
obradiKonekciju(conn, k)
}()
conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second)
if err != nil {
t.Fatalf("ne mogu da se povežem: %v", err)
}
defer conn.Close()
if _, err := conn.Write([]byte(`{"command":"status"}` + "\n")); err != nil {
t.Fatalf("greška pri slanju komande: %v", err)
}
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
line, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
t.Fatalf("greška pri čitanju odgovora: %v", err)
}
var resp map[string]any
if err := json.Unmarshal([]byte(line), &resp); err != nil {
t.Fatalf("odgovor nije validan JSON: %v (%q)", err, line)
}
if resp["status"] != "ok" {
t.Fatalf("status = %v, očekivano ok", resp["status"])
}
}
func TestPokreniOdbijaKadJeLimitDostignut(t *testing.T) {
// Konceptualna provera semafora u Pokreni je pokrivena kroz kod (kanal
// kapaciteta maxKonekcija); ovde samo proveravamo da konstanta ima razuman,
// pozitivan limit umesto neograničenog broja gorutina.
if maxKonekcija <= 0 {
t.Fatalf("maxKonekcija mora biti pozitivan broj, dobijeno: %d", maxKonekcija)
}
}