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:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user