From 2fb7c2d52959bd8324625a267313ff1f34863e1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Fri, 12 Jun 2026 22:07:32 +0200 Subject: [PATCH] test: prva grupa testova (kripto, model, RBAC, helperi, TOTP repo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dodati testovi pored koda: - internal/model: CenaBezPdv/PdvIznos, Klijent.PunoIme, PreostaloZaNaplatu - internal/middleware: ImaDozvolu matrica po ulogama + SveDozvole - internal/handler: izvuciIP, parseOpcionuCenu, validnoImeBackupa (anti path-traversal) - internal/db/sqlite: integracioni nad privremenom SQLite bazom + prave migracije (TOTP šifrovan u mirovanju, brisanje, ZasifrujPostojeceTotp + idempotentnost) 19 test funkcija, prolaze i sa -race. Dopunjava kripto_test.go iz ranije. --- internal/db/sqlite/korisnici_test.go | 139 +++++++++++++++++++++++++++ internal/handler/utils_test.go | 74 ++++++++++++++ internal/middleware/dozvole_test.go | 60 ++++++++++++ internal/model/model_test.go | 86 +++++++++++++++++ 4 files changed, 359 insertions(+) create mode 100644 internal/db/sqlite/korisnici_test.go create mode 100644 internal/handler/utils_test.go create mode 100644 internal/middleware/dozvole_test.go create mode 100644 internal/model/model_test.go diff --git a/internal/db/sqlite/korisnici_test.go b/internal/db/sqlite/korisnici_test.go new file mode 100644 index 0000000..feadf6b --- /dev/null +++ b/internal/db/sqlite/korisnici_test.go @@ -0,0 +1,139 @@ +package sqlite + +import ( + "context" + "crypto/rand" + "database/sql" + "os" + "path/filepath" + "testing" +) + +// testDB otvara privremenu SQLite bazu i primenjuje sve migracije. +// Migracije se čitaju iz korena repozitorijuma (../../.. u odnosu na ovaj paket). +func testDB(t *testing.T) *sql.DB { + t.Helper() + putanja := filepath.Join(t.TempDir(), "test.db") + db, err := OtvoriDB(putanja) + if err != nil { + t.Fatalf("OtvoriDB: %v", err) + } + if err := PokreniMigracije(db, os.DirFS("../../..")); err != nil { + t.Fatalf("migracije: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +func testKljuc(t *testing.T) []byte { + t.Helper() + k := make([]byte, 32) + if _, err := rand.Read(k); err != nil { + t.Fatalf("rand: %v", err) + } + return k +} + +// sirovaTotpTajna čita kolonu totp_tajna direktno (kakva stoji u bazi) +func sirovaTotpTajna(t *testing.T, db *sql.DB, id int64) string { + t.Helper() + var s sql.NullString + if err := db.QueryRow(`SELECT totp_tajna FROM korisnici WHERE id = ?`, id).Scan(&s); err != nil { + t.Fatalf("čitanje sirove tajne: %v", err) + } + return s.String +} + +// TOTP tajna se u bazi čuva šifrovana, a pri čitanju vraća kao čist tekst +func TestKorisniciTotpSifrovanUMirovanju(t *testing.T) { + ctx := context.Background() + db := testDB(t) + repo := NoviKorisniciRepo(db, testKljuc(t)) + + k, err := repo.Kreiraj(ctx, "pera", "hash", "radnik") + if err != nil { + t.Fatalf("Kreiraj: %v", err) + } + + const tajna = "JBSWY3DPEHPK3PXP" + if err := repo.SacuvajTotpTajnu(ctx, k.ID, tajna); err != nil { + t.Fatalf("SacuvajTotpTajnu: %v", err) + } + + // u bazi NIJE čist tekst + if sirova := sirovaTotpTajna(t, db, k.ID); sirova == tajna || sirova == "" { + t.Fatalf("tajna u bazi nije šifrovana (sirovo = %q)", sirova) + } + + // čitanje kroz repo vraća dešifrovanu (originalnu) tajnu + svezi, err := repo.DohvatiPoID(ctx, k.ID) + if err != nil { + t.Fatalf("DohvatiPoID: %v", err) + } + if svezi.TotpTajna != tajna { + t.Fatalf("dešifrovana tajna = %q, očekivano %q", svezi.TotpTajna, tajna) + } +} + +// brisanje TOTP-a (prazna tajna) upisuje NULL +func TestKorisniciTotpBrisanje(t *testing.T) { + ctx := context.Background() + db := testDB(t) + repo := NoviKorisniciRepo(db, testKljuc(t)) + + k, _ := repo.Kreiraj(ctx, "pera", "hash", "radnik") + _ = repo.SacuvajTotpTajnu(ctx, k.ID, "JBSWY3DPEHPK3PXP") + if err := repo.SacuvajTotpTajnu(ctx, k.ID, ""); err != nil { + t.Fatalf("brisanje: %v", err) + } + if sirova := sirovaTotpTajna(t, db, k.ID); sirova != "" { + t.Fatalf("posle brisanja tajna nije prazna: %q", sirova) + } + svezi, _ := repo.DohvatiPoID(ctx, k.ID) + if svezi.TotpTajna != "" { + t.Fatalf("posle brisanja TotpTajna = %q, očekivano prazno", svezi.TotpTajna) + } +} + +// ZasifrujPostojeceTotp pretvara staru nešifrovanu tajnu u šifrovanu, a stvarnu +// vrednost ostavlja čitljivom kroz repo (fallback) +func TestZasifrujPostojeceTotp(t *testing.T) { + ctx := context.Background() + db := testDB(t) + kljuc := testKljuc(t) + repo := NoviKorisniciRepo(db, kljuc) + + k, _ := repo.Kreiraj(ctx, "pera", "hash", "radnik") + + // simuliraj staru bazu: upiši čist tekst direktno (mimo repo-a) + const tajna = "JBSWY3DPEHPK3PXP" + if _, err := db.Exec(`UPDATE korisnici SET totp_tajna = ? WHERE id = ?`, tajna, k.ID); err != nil { + t.Fatalf("upis plain-text tajne: %v", err) + } + + // pre migracije repo i dalje vraća ispravnu tajnu (fallback na plain text) + if svezi, _ := repo.DohvatiPoID(ctx, k.ID); svezi.TotpTajna != tajna { + t.Fatalf("fallback nije vratio plain-text tajnu: %q", svezi.TotpTajna) + } + + br, err := ZasifrujPostojeceTotp(ctx, db, kljuc) + if err != nil { + t.Fatalf("ZasifrujPostojeceTotp: %v", err) + } + if br != 1 { + t.Fatalf("očekivano 1 šifrovan red, dobijeno %d", br) + } + + // sada je u bazi šifrovano, ali kroz repo i dalje čitljivo + if sirova := sirovaTotpTajna(t, db, k.ID); sirova == tajna { + t.Fatal("posle migracije tajna je i dalje plain text") + } + if svezi, _ := repo.DohvatiPoID(ctx, k.ID); svezi.TotpTajna != tajna { + t.Fatalf("posle migracije dešifrovana tajna = %q, očekivano %q", svezi.TotpTajna, tajna) + } + + // idempotentno: drugo pokretanje ne menja ništa + if br2, _ := ZasifrujPostojeceTotp(ctx, db, kljuc); br2 != 0 { + t.Fatalf("ponovno pokretanje šifrovalo %d redova, očekivano 0", br2) + } +} diff --git a/internal/handler/utils_test.go b/internal/handler/utils_test.go new file mode 100644 index 0000000..550aa22 --- /dev/null +++ b/internal/handler/utils_test.go @@ -0,0 +1,74 @@ +package handler + +import ( + "net/http" + "testing" +) + +func TestIzvuciIP(t *testing.T) { + testovi := []struct { + naziv string + realIP string + forwarded string + remoteAddr string + ocek string + }{ + {"X-Real-IP ima prioritet", "1.2.3.4", "9.9.9.9", "5.5.5.5:1234", "1.2.3.4"}, + {"poslednji X-Forwarded-For", "", "1.1.1.1, 2.2.2.2, 3.3.3.3", "5.5.5.5:1234", "3.3.3.3"}, + {"RemoteAddr bez porta kad nema zaglavlja", "", "", "5.5.5.5:1234", "5.5.5.5"}, + {"RemoteAddr kakav jeste ako nije host:port", "", "", "neispravan", "neispravan"}, + } + for _, tt := range testovi { + t.Run(tt.naziv, func(t *testing.T) { + r, _ := http.NewRequest("GET", "/", nil) + r.RemoteAddr = tt.remoteAddr + if tt.realIP != "" { + r.Header.Set("X-Real-IP", tt.realIP) + } + if tt.forwarded != "" { + r.Header.Set("X-Forwarded-For", tt.forwarded) + } + if got := izvuciIP(r); got != tt.ocek { + t.Errorf("izvuciIP() = %q, očekivano %q", got, tt.ocek) + } + }) + } +} + +func TestParseOpcionuCenu(t *testing.T) { + if got := parseOpcionuCenu(" "); got != nil { + t.Errorf("prazan unos → nil, dobijeno %v", *got) + } + if got := parseOpcionuCenu("abc"); got != nil { + t.Errorf("neispravan broj → nil, dobijeno %v", *got) + } + if got := parseOpcionuCenu("-5"); got != nil { + t.Errorf("negativan broj → nil, dobijeno %v", *got) + } + if got := parseOpcionuCenu("1250.50"); got == nil || *got != 1250.50 { + t.Errorf("ispravan broj → 1250.50, dobijeno %v", got) + } +} + +func TestValidnoImeBackupa(t *testing.T) { + ispravna := []string{"ntech_20260612_153000.db"} + for _, ime := range ispravna { + if !validnoImeBackupa.MatchString(ime) { + t.Errorf("%q bi trebalo da je ispravno", ime) + } + } + // anti path-traversal i neispravni formati + neispravna := []string{ + "../ntech_20260612_153000.db", + "ntech_20260612_153000.db.bak", + "ntech_2026_153000.db", + "ntech_20260612_153000.sqlite", + "/etc/passwd", + "ntech_20260612_153000.db/../x", + } + for _, ime := range neispravna { + if validnoImeBackupa.MatchString(ime) { + t.Errorf("%q NE bi smelo da prođe", ime) + } + } +} diff --git a/internal/middleware/dozvole_test.go b/internal/middleware/dozvole_test.go new file mode 100644 index 0000000..d150592 --- /dev/null +++ b/internal/middleware/dozvole_test.go @@ -0,0 +1,60 @@ +package middleware + +import "testing" + +func TestImaDozvoluSuperadmin(t *testing.T) { + // superadmin sme sve, uključujući akcije koje ni ne postoje + for _, akcija := range []string{"artikal.obrisi", "prodaja.storno", "backup.pokreni", "nepostojeca.akcija"} { + if !ImaDozvolu("superadmin", akcija) { + t.Errorf("superadmin bi morao da sme %q", akcija) + } + } +} + +func TestImaDozvoluAdmin(t *testing.T) { + sme := []string{"artikal.obrisi", "prodaja.storno", "backup.pokreni", "izvestaj.pregled", "podesavanja.izmeni", "dashboard.prihod"} + for _, a := range sme { + if !ImaDozvolu("admin", a) { + t.Errorf("admin bi morao da sme %q", a) + } + } + // admin ne sme nepostojeću akciju + if ImaDozvolu("admin", "nepostojeca.akcija") { + t.Error("admin ne bi smeo nepostojeću akciju") + } +} + +func TestImaDozvoluRadnik(t *testing.T) { + sme := []string{"kategorija.pregled", "dobavljac.dodaj", "servis.izmeni", "prodaja.dodaj", "klijent.izmeni", "tema.lokalno"} + for _, a := range sme { + if !ImaDozvolu("radnik", a) { + t.Errorf("radnik bi morao da sme %q", a) + } + } + // radnik NE sme: brisanje, storno, izveštaje, podešavanja, backup + neSme := []string{"prodaja.obrisi", "prodaja.storno", "servis.obrisi", "klijent.obrisi", + "dobavljac.obrisi", "izvestaj.pregled", "podesavanja.izmeni", "backup.pokreni", "dashboard.prihod"} + for _, a := range neSme { + if ImaDozvolu("radnik", a) { + t.Errorf("radnik NE bi smeo %q", a) + } + } +} + +func TestImaDozvoluNepoznataUloga(t *testing.T) { + if ImaDozvolu("gost", "kategorija.pregled") { + t.Error("nepoznata uloga ne sme ništa") + } +} + +func TestSveDozvolePokrivaSveAkcije(t *testing.T) { + m := SveDozvole("radnik") + if len(m) != len(sveAkcije) { + t.Fatalf("SveDozvole vraća %d akcija, očekivano %d", len(m), len(sveAkcije)) + } + for _, a := range sveAkcije { + if _, ok := m[a]; !ok { + t.Errorf("akcija %q nedostaje u SveDozvole", a) + } + } +} diff --git a/internal/model/model_test.go b/internal/model/model_test.go new file mode 100644 index 0000000..6964fd8 --- /dev/null +++ b/internal/model/model_test.go @@ -0,0 +1,86 @@ +package model + +import ( + "math" + "testing" +) + +// jednako proverava jednakost realnih brojeva uz malu toleranciju +func jednako(a, b float64) bool { + return math.Abs(a-b) < 0.0001 +} + +func ptr(v float64) *float64 { return &v } + +func TestArtikalCenaBezPdvIPdvIznos(t *testing.T) { + testovi := []struct { + naziv string + prodajna float64 + stopa float64 + ocekBezPdv float64 + ocekPdvIznos float64 + }{ + {"stopa 20%", 120, 20, 100, 20}, + {"stopa 0%", 100, 0, 100, 0}, + {"stopa 10%", 110, 10, 100, 10}, + {"nula cena", 0, 20, 0, 0}, + } + for _, tt := range testovi { + t.Run(tt.naziv, func(t *testing.T) { + a := Artikal{ProdajnaCena: tt.prodajna, PdvStopa: tt.stopa} + if got := a.CenaBezPdv(); !jednako(got, tt.ocekBezPdv) { + t.Errorf("CenaBezPdv() = %v, očekivano %v", got, tt.ocekBezPdv) + } + if got := a.PdvIznos(); !jednako(got, tt.ocekPdvIznos) { + t.Errorf("PdvIznos() = %v, očekivano %v", got, tt.ocekPdvIznos) + } + }) + } +} + +func TestKlijentPunoIme(t *testing.T) { + testovi := []struct { + naziv string + k Klijent + ocek string + }{ + {"pravno lice", Klijent{Tip: "pravno", NazivFirme: "Firma DOO", Ime: "x", Prezime: "y"}, "Firma DOO"}, + {"fizičko lice", Klijent{Tip: "fizicko", Ime: "Petar", Prezime: "Petrović"}, "Petar Petrović"}, + {"fizičko bez prezimena", Klijent{Tip: "fizicko", Ime: "Petar"}, "Petar"}, + {"fizičko prazno", Klijent{Tip: "fizicko"}, ""}, + } + for _, tt := range testovi { + t.Run(tt.naziv, func(t *testing.T) { + if got := tt.k.PunoIme(); got != tt.ocek { + t.Errorf("PunoIme() = %q, očekivano %q", got, tt.ocek) + } + }) + } +} + +func TestServisniNalogPreostaloZaNaplatu(t *testing.T) { + t.Run("bez konačne cene → nil", func(t *testing.T) { + n := ServisniNalog{CenaKonacna: nil, Avans: ptr(50)} + if got := n.PreostaloZaNaplatu(); got != nil { + t.Errorf("očekivan nil, dobijeno %v", *got) + } + }) + t.Run("konačna minus avans", func(t *testing.T) { + n := ServisniNalog{CenaKonacna: ptr(1000), Avans: ptr(300)} + if got := n.PreostaloZaNaplatu(); got == nil || !jednako(*got, 700) { + t.Errorf("očekivano 700, dobijeno %v", got) + } + }) + t.Run("bez avansa → puna cena", func(t *testing.T) { + n := ServisniNalog{CenaKonacna: ptr(1000)} + if got := n.PreostaloZaNaplatu(); got == nil || !jednako(*got, 1000) { + t.Errorf("očekivano 1000, dobijeno %v", got) + } + }) + t.Run("avans veći od cene → 0 (ne negativno)", func(t *testing.T) { + n := ServisniNalog{CenaKonacna: ptr(500), Avans: ptr(800)} + if got := n.PreostaloZaNaplatu(); got == nil || !jednako(*got, 0) { + t.Errorf("očekivano 0, dobijeno %v", got) + } + }) +}