test: prva grupa testova (kripto, model, RBAC, helperi, TOTP repo)

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.
This commit is contained in:
2026-06-12 22:07:32 +02:00
parent aa5cb62138
commit 2fb7c2d529
4 changed files with 359 additions and 0 deletions
+139
View File
@@ -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)
}
}
+74
View File
@@ -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)
}
}
}
+60
View File
@@ -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)
}
}
}
+86
View File
@@ -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)
}
})
}