diff --git a/Fisk/server.py b/Fisk/server.py index 847ca38..9793eb1 100755 --- a/Fisk/server.py +++ b/Fisk/server.py @@ -31,6 +31,7 @@ ESIR_ID = "NTECH001" # naš 8-char ESIR identifikator # Kartica emulator (NTech goroutine) BE_HOST = os.environ.get("BE_HOST", "127.0.0.1") BE_PORT = int(os.environ.get("BE_PORT", "4567")) +BE_PIN = os.environ.get("BE_PIN", "1234") DATA_DIR = Path(__file__).parent / "data" INVOICES_DIR = DATA_DIR / "invoices" @@ -86,6 +87,25 @@ def _ucitaj_fiskalni_pismo(): FISKALNI_PISMO = _ucitaj_fiskalni_pismo() +_be_pin_verifikovan = False + + +def _osiguraj_be_pin() -> bool: + """Verifikuje PIN kod kartica emulatora ako to još nije učinjeno u ovom + procesu — emulator (NTech, internal/be/kartica.go) posle be#34 odbija + "sign" dok se prethodno ne pozove "verify_pin". pinUnesen je stanje na + deljenoj Kartica instanci (globalno za sve TCP konekcije), pa je dovoljno + da se verifikacija uspešno izvrši jednom po životnom veku NTech procesa.""" + global _be_pin_verifikovan + if _be_pin_verifikovan: + return True + resp = be_command({"command": "verify_pin", "pin": BE_PIN}) + _be_pin_verifikovan = resp.get("status") == "ok" + if not _be_pin_verifikovan: + log(f" ⚠️ automatska PIN verifikacija ka kartica emulatoru nije uspela: {resp}") + return _be_pin_verifikovan + + def be_command(cmd: dict) -> dict: """Šalje JSON komandu kartica emulatoru (NTech TCP :4567) i vraća odgovor.""" try: @@ -338,6 +358,7 @@ def _build_invoice_response(req, request_id): # Kartica emulator: podatke firme i potpis/brojače cert = be_command({"command": "certificate"}) + _osiguraj_be_pin() sign = be_command({ "command": "sign", "invoice_type": invoice_type, @@ -347,6 +368,11 @@ def _build_invoice_response(req, request_id): if sign.get("status") == "blocked": raise RuntimeError(f"Kartica blokirana: {sign.get('message')}") + if sign.get("status") != "ok": + # npr. "error"/2101 (PIN nije verifikovan) — ne sme tiho da propadne u + # podrazumevane vrednosti counter=1 (v. BUG.md #34), jer bi to izdalo + # fiskalni broj koji se ponavlja iz računa u račun. + raise RuntimeError(f"Kartica potpis nije uspeo: {sign.get('message', sign.get('status'))}") jid = cert.get("jid", ESIR_ID) total_cnt = sign.get("counter", 1) diff --git a/internal/be/kartica.go b/internal/be/kartica.go index f5beab1..7c95c18 100644 --- a/internal/be/kartica.go +++ b/internal/be/kartica.go @@ -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{ diff --git a/internal/be/kartica_test.go b/internal/be/kartica_test.go new file mode 100644 index 0000000..19a52f0 --- /dev/null +++ b/internal/be/kartica_test.go @@ -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) + } +} diff --git a/internal/be/server.go b/internal/be/server.go index 08181e4..8607676 100644 --- a/internal/be/server.go +++ b/internal/be/server.go @@ -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 diff --git a/internal/be/server_test.go b/internal/be/server_test.go new file mode 100644 index 0000000..8ec8eda --- /dev/null +++ b/internal/be/server_test.go @@ -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) + } +} diff --git a/internal/config/setup.go b/internal/config/setup.go index 58ad5c4..88a5ddf 100644 --- a/internal/config/setup.go +++ b/internal/config/setup.go @@ -45,6 +45,29 @@ func nadjiLokalneAdrese() []string { return adrese } +// obradiSetupPotvrdi prima izabrani port sa setup stranice, upisuje ntech.env i +// signalizira gotov kanalu da server može da se ugasi. Odbija nevažeći JSON i +// portove van dozvoljenog opsega (v. BUG.md #38 — ranije se greška dekodiranja +// tiho gutala i upisivala port 0). +func obradiSetupPotvrdi(envFajl string, gotov chan<- struct{}) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + var telo struct { + Port int `json:"port"` + } + if err := json.NewDecoder(req.Body).Decode(&telo); err != nil || telo.Port <= 0 || telo.Port > 65535 { + http.Error(w, "Neispravan port", http.StatusBadRequest) + return + } + if err := SacuvajEnv(telo.Port, envFajl); err != nil { + http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"ok": true}) + close(gotov) + } +} + // PokreniSetup pokreće HTTP server za prvo podešavanje i čeka da korisnik završi func PokreniSetup(fsys fs.FS, envFajl string) { port := NadjiSlobodanPort() @@ -86,19 +109,7 @@ func PokreniSetup(fsys fs.FS, envFajl string) { }) // prima izabrani port, upisuje ntech.env i signalizira završetak - r.Post("/setup/potvrdi", func(w http.ResponseWriter, req *http.Request) { - var telo struct { - Port int `json:"port"` - } - json.NewDecoder(req.Body).Decode(&telo) - if err := SacuvajEnv(telo.Port, envFajl); err != nil { - http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]bool{"ok": true}) - close(gotov) - }) + r.Post("/setup/potvrdi", obradiSetupPotvrdi(envFajl, gotov)) srv := &http.Server{Addr: adresa, Handler: r} diff --git a/internal/config/setup_test.go b/internal/config/setup_test.go new file mode 100644 index 0000000..8ac9e4a --- /dev/null +++ b/internal/config/setup_test.go @@ -0,0 +1,81 @@ +package config + +import ( + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestObradiSetupPotvrdiNevazeciJsonOdbija(t *testing.T) { + envFajl := filepath.Join(t.TempDir(), "ntech.env") + gotov := make(chan struct{}) + + req := httptest.NewRequest("POST", "/setup/potvrdi", strings.NewReader("nije json")) + w := httptest.NewRecorder() + obradiSetupPotvrdi(envFajl, gotov)(w, req) + + if w.Code != 400 { + t.Fatalf("status = %d, očekivano 400 za nevažeći JSON", w.Code) + } + if _, err := os.Stat(envFajl); err == nil { + t.Fatal("ntech.env ne sme biti upisan kad je JSON nevažeći") + } + select { + case <-gotov: + t.Fatal("gotov kanal ne sme biti zatvoren kad zahtev nije uspeo") + default: + } +} + +func TestObradiSetupPotvrdiPortNulaOdbija(t *testing.T) { + envFajl := filepath.Join(t.TempDir(), "ntech.env") + gotov := make(chan struct{}) + + req := httptest.NewRequest("POST", "/setup/potvrdi", strings.NewReader(`{"port":0}`)) + w := httptest.NewRecorder() + obradiSetupPotvrdi(envFajl, gotov)(w, req) + + if w.Code != 400 { + t.Fatalf("status = %d, očekivano 400 za port 0", w.Code) + } +} + +func TestObradiSetupPotvrdiPortVanOpsegaOdbija(t *testing.T) { + envFajl := filepath.Join(t.TempDir(), "ntech.env") + gotov := make(chan struct{}) + + req := httptest.NewRequest("POST", "/setup/potvrdi", strings.NewReader(`{"port":99999}`)) + w := httptest.NewRecorder() + obradiSetupPotvrdi(envFajl, gotov)(w, req) + + if w.Code != 400 { + t.Fatalf("status = %d, očekivano 400 za port van opsega", w.Code) + } +} + +func TestObradiSetupPotvrdiValidanPortUpisuje(t *testing.T) { + envFajl := filepath.Join(t.TempDir(), "ntech.env") + gotov := make(chan struct{}) + + req := httptest.NewRequest("POST", "/setup/potvrdi", strings.NewReader(`{"port":8080}`)) + w := httptest.NewRecorder() + obradiSetupPotvrdi(envFajl, gotov)(w, req) + + if w.Code != 200 { + t.Fatalf("status = %d, očekivano 200 za validan port", w.Code) + } + sadrzaj, err := os.ReadFile(envFajl) + if err != nil { + t.Fatalf("ntech.env nije upisan: %v", err) + } + if !strings.Contains(string(sadrzaj), "NTECH_PORT=8080") { + t.Errorf("ntech.env sadržaj = %q, očekivano NTECH_PORT=8080", sadrzaj) + } + select { + case <-gotov: + default: + t.Fatal("gotov kanal mora biti zatvoren posle uspešnog zahteva") + } +} diff --git a/internal/fiskal/fiskal_test.go b/internal/fiskal/fiskal_test.go new file mode 100644 index 0000000..59c58a7 --- /dev/null +++ b/internal/fiskal/fiskal_test.go @@ -0,0 +1,160 @@ +package fiskal + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func testniZahtev() InvoiceRequest { + return InvoiceRequest{ + InvoiceRequest: InvoiceRequestBody{ + InvoiceType: "Normal", + TransactionType: "Sale", + Payment: []PaymentItem{{Amount: 100, PaymentType: "Cash"}}, + Items: []InvoiceItem{{Name: "Test", TotalAmount: 100, UnitPrice: 100, Quantity: 1}}, + Cashier: "Test Kasir", + }, + } +} + +func TestIzdajRacunUspeh(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/invoices" { + t.Fatalf("neočekivan zahtev: %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Fatalf("Authorization header = %q, očekivano Bearer test-key", got) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"invoiceNumber":"ПП123","totalAmount":100,"totalTax":16.67}`)) + })) + defer srv.Close() + + k := NoviKlijent(srv.URL, "test-key") + odgovor, err := k.IzdajRacun(context.Background(), testniZahtev()) + if err != nil { + t.Fatalf("IzdajRacun greška: %v", err) + } + if odgovor.InvoiceNumber != "ПП123" { + t.Errorf("InvoiceNumber = %q, očekivano ПП123", odgovor.InvoiceNumber) + } + if odgovor.TotalAmount != 100 { + t.Errorf("TotalAmount = %v, očekivano 100", odgovor.TotalAmount) + } +} + +func TestIzdajRacunHttpGreska(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"message":"nevažeći zahtev"}`)) + })) + defer srv.Close() + + k := NoviKlijent(srv.URL, "test-key") + if _, err := k.IzdajRacun(context.Background(), testniZahtev()); err == nil { + t.Fatal("očekivana greška za HTTP 400, dobijeno nil") + } +} + +func TestIzdajRacunServerNedostupan(t *testing.T) { + // port na kome sigurno niko ne sluša (zatvoren odmah posle otvaranja) + ln := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + url := ln.URL + ln.Close() + + k := NoviKlijent(url, "test-key") + if _, err := k.IzdajRacun(context.Background(), testniZahtev()); err == nil { + t.Fatal("očekivana greška kad server nije dostupan, dobijeno nil") + } +} + +func TestIzdajRacunKontekstIstekao(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(50 * time.Millisecond) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer srv.Close() + + k := NoviKlijent(srv.URL, "test-key") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) + defer cancel() + if _, err := k.IzdajRacun(ctx, testniZahtev()); err == nil { + t.Fatal("očekivana greška kad kontekst istekne pre odgovora, dobijeno nil") + } +} + +func TestIzdajRacunPrezivljavaOtkazivanjeOriginalnogKonteksta(t *testing.T) { + // simulira #35 iz BUG.md: HTTP handler-ov r.Context() se otkaže (npr. klijent + // zatvori konekciju), ali fiskalni poziv nastavlja preko odvojenog konteksta. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(30 * time.Millisecond) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"invoiceNumber":"ПП999"}`)) + })) + defer srv.Close() + + originalni, otkazi := context.WithCancel(context.Background()) + odvojeni := context.WithoutCancel(originalni) + odvojeni, cancel := context.WithTimeout(odvojeni, time.Second) + defer cancel() + + // otkaži originalni kontekst odmah — kao da je browser konekcija prekinuta + otkazi() + + k := NoviKlijent(srv.URL, "test-key") + odgovor, err := k.IzdajRacun(odvojeni, testniZahtev()) + if err != nil { + t.Fatalf("fiskalni poziv nije smeo da pukne posle otkazivanja originalnog konteksta: %v", err) + } + if odgovor.InvoiceNumber != "ПП999" { + t.Errorf("InvoiceNumber = %q, očekivano ПП999", odgovor.InvoiceNumber) + } +} + +func TestStatusUspeh(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/status" { + t.Fatalf("neočekivana putanja: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok"}`)) + })) + defer srv.Close() + + k := NoviKlijent(srv.URL, "") + rezultat, err := k.Status(context.Background()) + if err != nil { + t.Fatalf("Status greška: %v", err) + } + if rezultat["status"] != "ok" { + t.Errorf("status = %v, očekivano ok", rezultat["status"]) + } +} + +func TestZakljuciDanNoContent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Fatalf("očekivan DELETE, dobijeno %s", r.Method) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + k := NoviKlijent(srv.URL, "") + if err := k.ZakljuciDan(context.Background()); err != nil { + t.Fatalf("ZakljuciDan greška: %v", err) + } +} + +func TestNoviKlijentSkidaKrajnjuKosuCrtu(t *testing.T) { + k := NoviKlijent("http://127.0.0.1:4566/", "kljuc") + if k.BaseURL != "http://127.0.0.1:4566" { + t.Errorf("BaseURL = %q, očekivano bez krajnje kose crte", k.BaseURL) + } +} diff --git a/internal/handler/dobavljac.go b/internal/handler/dobavljac.go index 9d036d7..2510f1a 100644 --- a/internal/handler/dobavljac.go +++ b/internal/handler/dobavljac.go @@ -36,7 +36,7 @@ func (h *Handler) Dobavljaci(w http.ResponseWriter, r *http.Request) { return } - pretraga := r.URL.Query().Get("pretraga") + pretraga := ignorisiKratkuPretragu(r.URL.Query().Get("pretraga")) dobavljaci, err := h.DobavljaciRepo.Lista(r.Context(), pretraga) if err != nil { diff --git a/internal/handler/fiskal_pazar.go b/internal/handler/fiskal_pazar.go index 28e0bc9..7bb6ccd 100644 --- a/internal/handler/fiskal_pazar.go +++ b/internal/handler/fiskal_pazar.go @@ -63,7 +63,7 @@ func (h *Handler) FiskalniPazar(w http.ResponseWriter, r *http.Request) { return } - klijent := h.fiskalKlijent() + klijent := h.fiskalKlijent(r.Context()) if klijent == nil { podaci.Greska = "Fiskalizacija nije podešena — unesi URL PFR servera u Podešavanja → Fiskalizacija." h.renderujTemplate(w, "fiskal_pazar", podaci) @@ -94,7 +94,7 @@ func (h *Handler) ZakljuciFiskalniDan(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther) return } - klijent := h.fiskalKlijent() + klijent := h.fiskalKlijent(r.Context()) if klijent == nil { middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.") http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther) @@ -127,7 +127,7 @@ func (h *Handler) FiskalniIzvestaj(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther) return } - klijent := h.fiskalKlijent() + klijent := h.fiskalKlijent(r.Context()) if klijent == nil { middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.") http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 8069c63..05e1396 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" "sync" + "time" "ntech/internal/config" "ntech/internal/db" @@ -276,9 +277,9 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s // fiskalKlijent vraća inicijalizovan fiskalni HTTP klijent na osnovu trenutnih // podešavanja. Vraća nil ako pfr_url nije podešen. URL se čita dinamički pri // svakom pozivu — nema keširanja, pa promena podešavanja stupa na snagu odmah. -func (h *Handler) fiskalKlijent() *fiskal.Klijent { - url, _ := sqlite.DohvatiPodesavanje(context.Background(), h.DB, "pfr_url") - key, _ := sqlite.DohvatiPodesavanje(context.Background(), h.DB, "pfr_api_key") +func (h *Handler) fiskalKlijent(ctx context.Context) *fiskal.Klijent { + url, _ := sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_url") + key, _ := sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_api_key") if url == "" { return nil } @@ -288,6 +289,17 @@ func (h *Handler) fiskalKlijent() *fiskal.Klijent { return fiskal.NoviKlijent(url, key) } +// odvojenKontekstFiskalizacije vraća kontekst koji preživljava otkazivanje/prekid +// originalnog HTTP zahteva (npr. korisnik zatvori tab, proxy timeout) — fiskalni +// poziv ka PFR serveru i upis lokalnog zapisa moraju da se završe i kad browser +// konekcija padne, jer je fiskalizacija nepovratna radnja (ponovni pokušaj bez +// ovoga bi mogao izdati duplirani fiskalni račun). Vrednosti iz ctx (npr. korisnik +// iz middleware) ostaju dostupne; samo se otkazivanje/deadline originalnog ctx +// ignoriše u korist sopstvenog, ograničenog roka. +func odvojenKontekstFiskalizacije(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.WithoutCancel(ctx), 20*time.Second) +} + // imeKasira vraća ime i prezime prijavljenog korisnika za polje "kasir" na fiskalnom // računu (korisničko ime kao rezerva ako ime/prezime nisu uneti). Pada nazad na // podešavanje pfr_kasir (npr. za pozadinske pozive bez korisnika u kontekstu), pa na diff --git a/internal/handler/magacin.go b/internal/handler/magacin.go index 902a109..b7a782a 100644 --- a/internal/handler/magacin.go +++ b/internal/handler/magacin.go @@ -53,7 +53,7 @@ func (h *Handler) magacinPrikaz(w http.ResponseWriter, r *http.Request, tip, str } filter := db.ArtikalFilter{ - Pretraga: r.URL.Query().Get("pretraga"), + Pretraga: ignorisiKratkuPretragu(r.URL.Query().Get("pretraga")), Tip: tip, SamoKriticni: r.URL.Query().Get("kriticni") == "1", Arhivirani: r.URL.Query().Get("arhivirani") == "1", diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go index 2f758b4..241c485 100644 --- a/internal/handler/prodaja.go +++ b/internal/handler/prodaja.go @@ -320,7 +320,7 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) { // Fiskalizacija — ako je modul uključen (best-effort: prodaja ostaje validna i bez fiskalizacije) racunKreiran := false if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) { - if klijent := h.fiskalKlijent(); klijent != nil { + if klijent := h.fiskalKlijent(r.Context()); klijent != nil { primljeno, _ := strconv.ParseFloat(strings.TrimSpace(r.FormValue("primljeno")), 64) h.fiskalizujProdaju(r.Context(), id, klijent, primljeno) if fr, _ := h.FiskalRepo.DohvatiPoProdaji(r.Context(), id); fr != nil { @@ -729,6 +729,8 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string, // stornirajProdaju (odmah posle storna) i ručni retry (PokusajRefundProdaje) kad prvi // pokušaj padne. poreskiBrojKupca se koristi samo ako klijent nema PIB/JMBG na kartici. func (h *Handler) posaljiFiskalniRefundProdaje(ctx context.Context, id int64, poreskiBrojKupca string) error { + ctx, cancel := odvojenKontekstFiskalizacije(ctx) + defer cancel() fr, err := h.FiskalRepo.DohvatiPoProdaji(ctx, id) if err != nil { return err @@ -736,7 +738,7 @@ func (h *Handler) posaljiFiskalniRefundProdaje(ctx context.Context, id int64, po if fr == nil || fr.Storniran || fr.TipTransakcije != "Sale" { return nil } - fk := h.fiskalKlijent() + fk := h.fiskalKlijent(ctx) if fk == nil { return errors.New("fiskalizacija nije podešena") } @@ -827,6 +829,8 @@ func (h *Handler) PokusajRefundProdaje(w http.ResponseWriter, r *http.Request) { // primljeno je iznos koji je kupac predao (za tačan povraćaj na računu) — 0 ako // nije poznat (npr. ručni retry), tada se šalje tačan dug bez povraćaja. func (h *Handler) fiskalizujProdaju(ctx context.Context, prodajaID int64, klijent *fiskal.Klijent, primljeno float64) { + ctx, cancel := odvojenKontekstFiskalizacije(ctx) + defer cancel() nalog, err := h.ProdajaRepo.DohvatiID(ctx, prodajaID) if err != nil { slog.Error("fiskalizujProdaju: nije pronađen nalog", "prodaja_id", prodajaID, "error", err) @@ -905,7 +909,7 @@ func (h *Handler) RetryFiskalizacijaProdaje(w http.ResponseWriter, r *http.Reque return } - klijent := h.fiskalKlijent() + klijent := h.fiskalKlijent(r.Context()) if klijent == nil { middleware.SetFlash(w, r, h.DB, "greska", "Fiskalni servis nije dostupan. Proverite vezu sa ESIR/PFR.") http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther) diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 93b97cd..c4cdcbc 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -656,7 +656,7 @@ func (h *Handler) StornoNaloga(w http.ResponseWriter, r *http.Request) { // fiskalni refund — best-effort if originalniFiskal != nil { - if fk := h.fiskalKlijent(); fk != nil { + if fk := h.fiskalKlijent(ctx); fk != nil { if err := h.refundujServis(ctx, id, fk, originalniFiskal.ID, originalniFiskal.PfrBroj, poreskiBrojKupca); err != nil { slog.Error("fiskalni refund servisa nije uspeo", "servis_id", id, "error", err) } @@ -691,7 +691,7 @@ func (h *Handler) PokusajRefundServisa(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther) return } - fk := h.fiskalKlijent() + fk := h.fiskalKlijent(ctx) if fk == nil { middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.") http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther) @@ -2447,7 +2447,7 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) { // konačan račun da zatvori avans i pokrene eventualni povraćaj viška. if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) { if fr, _ := h.FiskalRepo.DohvatiPoServisuITip(r.Context(), id, "Normal", "Sale"); fr == nil { - klijent := h.fiskalKlijent() + klijent := h.fiskalKlijent(r.Context()) if klijent != nil { h.fiskalizujServis(r.Context(), id, klijent, nacin, iznos, primljeno) } @@ -2512,6 +2512,8 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) { // avansa ne fiskalizuje ceo iznos ponovo. Bez efekta ako razlika nije pozitivna // (smanjenje avansa se ne fiskalizuje automatski — v. razgovor o povraćaju). func (h *Handler) fiskalizujAvansServisa(ctx context.Context, servisID int64, noviAvans float64, nacinPlacanja string) { + ctx, cancel := odvojenKontekstFiskalizacije(ctx) + defer cancel() if noviAvans <= 0 { return } @@ -2527,7 +2529,7 @@ func (h *Handler) fiskalizujAvansServisa(ctx context.Context, servisID int64, no if delta <= 0 { return } - klijent := h.fiskalKlijent() + klijent := h.fiskalKlijent(ctx) if klijent == nil { return } @@ -2580,6 +2582,8 @@ func (h *Handler) fiskalizujAvansServisa(ctx context.Context, servisID int64, no // na računu se iskazuje stvarno primljen iznos i PFR sam izračunava povraćaj // (isto kao fiskal.NapraviZahtev za Prodaju); 0 ili manje od duga → bez povraćaja. func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent *fiskal.Klijent, nacinPlacanja string, iznos, primljeno float64) { + ctx, cancel := odvojenKontekstFiskalizacije(ctx) + defer cancel() nalog, err := h.ServisRepo.DohvatiID(ctx, servisID) if err != nil { slog.Error("fiskalizujServis: nije pronađen nalog", "id", servisID, "error", err) @@ -2782,6 +2786,8 @@ func stavkeFiskalnogServisa(radovi []model.ServisniRad, delovi []model.ServisniD // refunda; PraznineKnjigovodstva prati takve slučajeve preko stornoBezRefunda). Vraća // grešku i pozivaocu (npr. PokusajRefundServisa) da bi ručni retry mogao da je prikaže. func (h *Handler) refundujServis(ctx context.Context, servisID int64, klijent *fiskal.Klijent, originalniFiskalID int64, referentBroj, poreskiBrojKupca string) error { + ctx, cancel := odvojenKontekstFiskalizacije(ctx) + defer cancel() nalog, err := h.ServisRepo.DohvatiID(ctx, servisID) if err != nil { slog.Error("refundujServis: nije pronađen nalog", "id", servisID, "error", err) @@ -2902,7 +2908,7 @@ func (h *Handler) RetryFiskalizacija(w http.ResponseWriter, r *http.Request) { // Naplaceno==0 je dozvoljeno kad avans u potpunosti pokriva cenu — konačan // račun se i tada mora izdati da zatvori avans (i eventualno vrati višak) - klijent := h.fiskalKlijent() + klijent := h.fiskalKlijent(r.Context()) if klijent == nil { middleware.SetFlash(w, r, h.DB, "greska", "Fiskalni servis nije dostupan. Proverite vezu sa ESIR/PFR.") http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther) diff --git a/internal/handler/troskovi.go b/internal/handler/troskovi.go index 29c4af6..5eb93c3 100644 --- a/internal/handler/troskovi.go +++ b/internal/handler/troskovi.go @@ -38,7 +38,7 @@ func (h *Handler) Troskovi(w http.ResponseWriter, r *http.Request) { return } - pretraga := r.URL.Query().Get("pretraga") + pretraga := ignorisiKratkuPretragu(r.URL.Query().Get("pretraga")) troskovi, err := h.TroskoviRepo.Lista(r.Context(), db.TrosakFilter{Pretraga: pretraga}) if err != nil { http.Error(w, "Greška pri učitavanju troškova", http.StatusInternalServerError) diff --git a/internal/handler/usluge.go b/internal/handler/usluge.go index bef475f..99844a4 100644 --- a/internal/handler/usluge.go +++ b/internal/handler/usluge.go @@ -39,7 +39,7 @@ func (h *Handler) Usluge(w http.ResponseWriter, r *http.Request) { return } - pretraga := r.URL.Query().Get("pretraga") + pretraga := ignorisiKratkuPretragu(r.URL.Query().Get("pretraga")) usluge, err := h.UslugeRepo.Lista(r.Context(), db.UslugaFilter{Pretraga: pretraga}) if err != nil { http.Error(w, "Greška pri učitavanju usluga", http.StatusInternalServerError) diff --git a/internal/handler/utils.go b/internal/handler/utils.go index 09e9173..e341a4f 100644 --- a/internal/handler/utils.go +++ b/internal/handler/utils.go @@ -3,6 +3,7 @@ package handler import ( "fmt" "strconv" + "strings" ) // parseID parsira string ID iz URL parametra u int64 @@ -13,3 +14,15 @@ func parseID(s string) (int64, error) { } return id, nil } + +// ignorisiKratkuPretragu vraća pretragu nepromenjenu ako je prazna ili ima 3+ +// znaka, a inače "" (1-2 znaka se ignorišu). Ranije je ovo bio klijentski +// hx-trigger uslov (keyup[length==0||length>=3]) — uklonjen jer ga htmx +// evaluira preko eval()/new Function(), što CSP bez 'unsafe-eval' blokira +// (v. BUG.md #41). Ovde se ista optimizacija radi na serveru, bez eval-a. +func ignorisiKratkuPretragu(pretraga string) string { + if l := len([]rune(strings.TrimSpace(pretraga))); l > 0 && l < 3 { + return "" + } + return pretraga +} diff --git a/web/static/js/ntech.js b/web/static/js/ntech.js index 9b57ab3..df8134a 100644 --- a/web/static/js/ntech.js +++ b/web/static/js/ntech.js @@ -21,6 +21,18 @@ window.addEventListener('pageshow', function(e) { }); }); +// otvara nativni date/time picker na BILO KOJI klik unutar polja, ne samo na +// malu kalendar/sat ikonicu koju browser crta — u Firefoxu (i na nekim +// sistemima/rezolucijama) ta ikonica ima vrlo uzak klik-hitbox, pa korisnik +// klikne tik pored nje i pomisli da "klik na kalendar ne radi ništa". +// showPicker() zahteva user-gesture kontekst — click handler to ispunjava. +document.addEventListener('click', function(e) { + var el = e.target.closest('input[type="date"], input[type="time"], input[type="datetime-local"], input[type="month"], input[type="week"]'); + if (el && typeof el.showPicker === 'function') { + try { el.showPicker(); } catch (err) { /* readonly/disabled i sl. — ignoriši */ } + } +}); + // otvara/zatvara podmeni u sidebaru — radi i kad je sidebar skupljen i kad je proširen // (sidebar ostaje u zatečenom stanju). U isto vreme sme biti otvoren samo jedan podmeni. function ntechTogglePodmeni(btn) { diff --git a/web/templates/stranice/dobavljaci.html b/web/templates/stranice/dobavljaci.html index 2cb735c..fe3bd4b 100644 --- a/web/templates/stranice/dobavljaci.html +++ b/web/templates/stranice/dobavljaci.html @@ -20,7 +20,7 @@ diff --git a/web/templates/stranice/izvestaji.html b/web/templates/stranice/izvestaji.html index 6525c71..8e026c9 100644 --- a/web/templates/stranice/izvestaji.html +++ b/web/templates/stranice/izvestaji.html @@ -46,7 +46,7 @@ -
+
@@ -98,7 +98,7 @@
Stari otvoreni nalozi — bez završetka duže od 14 dana
{{if .StariNalozi}} -
+
@@ -143,6 +143,7 @@
Najprodavaniji artikli — top 10
{{if .TopArtikli}} +
@@ -168,6 +169,7 @@ {{end}}
+
{{else}}
Nema podataka o prodaji.
{{end}} @@ -177,6 +179,7 @@
Najvažniji klijenti — top 10
{{if .TopKlijenti}} +
@@ -199,6 +202,7 @@ {{end}}
+
{{else}}
Nema podataka o klijentima.
{{end}} diff --git a/web/templates/stranice/kpo.html b/web/templates/stranice/kpo.html index 920a20b..8ba3093 100644 --- a/web/templates/stranice/kpo.html +++ b/web/templates/stranice/kpo.html @@ -56,7 +56,7 @@
{{if .Zapisi}} -
+
diff --git a/web/templates/stranice/magacin.html b/web/templates/stranice/magacin.html index 2725337..354a8f0 100644 --- a/web/templates/stranice/magacin.html +++ b/web/templates/stranice/magacin.html @@ -40,7 +40,7 @@