Compare commits
2 Commits
318e6e00fc
...
74d52e05c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74d52e05c1 | ||
|
|
04fd1678be |
17
TESTING.md
17
TESTING.md
@ -1,8 +1,8 @@
|
|||||||
# DAL License Server — Test Checklista
|
# DAL License Server — Test Checklista
|
||||||
|
|
||||||
## Ukupno testova: 179
|
## Ukupno testova: 182
|
||||||
- Go unit testovi: 46
|
- Go unit testovi: 46
|
||||||
- Playwright E2E testovi: 133
|
- Playwright E2E testovi: 136
|
||||||
|
|
||||||
## Pokretanje testova
|
## Pokretanje testova
|
||||||
|
|
||||||
@ -92,11 +92,12 @@ go test ./internal/... -v -count=1 && npx playwright test
|
|||||||
|
|
||||||
## Playwright E2E Testovi (133)
|
## Playwright E2E Testovi (133)
|
||||||
|
|
||||||
### Login stranica (15 testova)
|
### Login stranica (18 testova)
|
||||||
- [x] Prikazuje login formu sa svim elementima
|
- [x] Prikazuje login formu sa svim elementima (username + password)
|
||||||
- [x] Password polje ima autofocus
|
- [x] Username polje ima autofocus
|
||||||
- [x] Forma ima ispravnu action i method
|
- [x] Forma ima ispravnu action i method
|
||||||
- [x] Prijava sa ispravnom lozinkom preusmerava na dashboard
|
- [x] Prijava sa ispravnim username i password preusmerava na dashboard
|
||||||
|
- [x] Prijava sa pogresnim username-om prikazuje gresku
|
||||||
- [x] Prijava sa pogresnom lozinkom prikazuje gresku
|
- [x] Prijava sa pogresnom lozinkom prikazuje gresku
|
||||||
- [x] Prijava sa praznom lozinkom (browser validacija)
|
- [x] Prijava sa praznom lozinkom (browser validacija)
|
||||||
- [x] Razlicite pogresne lozinke (7 pokusaja)
|
- [x] Razlicite pogresne lozinke (7 pokusaja)
|
||||||
@ -108,6 +109,8 @@ go test ./internal/... -v -count=1 && npx playwright test
|
|||||||
- [x] CSS je ucitan
|
- [x] CSS je ucitan
|
||||||
- [x] Ispravan page title
|
- [x] Ispravan page title
|
||||||
- [x] Visestruki logini kreiraju razlicite sesije
|
- [x] Visestruki logini kreiraju razlicite sesije
|
||||||
|
- [x] Navbar prikazuje ime korisnika posle logina
|
||||||
|
- [x] Username i password labele su prikazane
|
||||||
|
|
||||||
### Dashboard stranica (18 testova)
|
### Dashboard stranica (18 testova)
|
||||||
- [x] Prikazuje naslov Dashboard
|
- [x] Prikazuje naslov Dashboard
|
||||||
@ -255,4 +258,4 @@ go test ./internal/... -v -count=1 && npx playwright test
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Poslednje azuriranje: 04.03.2026 — 179 testova (46 Go + 133 Playwright)*
|
*Poslednje azuriranje: 04.03.2026 — 182 testova (46 Go + 136 Playwright)*
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"dal-license-server/internal/config"
|
"dal-license-server/internal/config"
|
||||||
"dal-license-server/internal/handler"
|
"dal-license-server/internal/handler"
|
||||||
|
"dal-license-server/internal/model"
|
||||||
"dal-license-server/internal/repository"
|
"dal-license-server/internal/repository"
|
||||||
"dal-license-server/internal/router"
|
"dal-license-server/internal/router"
|
||||||
"dal-license-server/internal/service"
|
"dal-license-server/internal/service"
|
||||||
@ -14,6 +15,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
_ "github.com/go-sql-driver/mysql"
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -43,6 +45,10 @@ func main() {
|
|||||||
licenseRepo := repository.NewLicenseRepo(db)
|
licenseRepo := repository.NewLicenseRepo(db)
|
||||||
activationRepo := repository.NewActivationRepo(db)
|
activationRepo := repository.NewActivationRepo(db)
|
||||||
auditRepo := repository.NewAuditRepo(db)
|
auditRepo := repository.NewAuditRepo(db)
|
||||||
|
userRepo := repository.NewUserRepo(db)
|
||||||
|
|
||||||
|
// Seed default admin user
|
||||||
|
seedDefaultAdmin(userRepo, cfg.AdminPassword)
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
licenseSvc := service.NewLicenseService(licenseRepo, auditRepo)
|
licenseSvc := service.NewLicenseService(licenseRepo, auditRepo)
|
||||||
@ -51,7 +57,7 @@ func main() {
|
|||||||
// Handlers
|
// Handlers
|
||||||
clientHandler := handler.NewClientHandler(activationSvc)
|
clientHandler := handler.NewClientHandler(activationSvc)
|
||||||
adminHandler := handler.NewAdminHandler(licenseSvc, activationSvc, auditRepo)
|
adminHandler := handler.NewAdminHandler(licenseSvc, activationSvc, auditRepo)
|
||||||
dashboardHandler := handler.NewDashboardHandler(licenseSvc, activationSvc, auditRepo, "templates", cfg.AdminPassword)
|
dashboardHandler := handler.NewDashboardHandler(licenseSvc, activationSvc, auditRepo, userRepo, "templates")
|
||||||
|
|
||||||
// Rate limits
|
// Rate limits
|
||||||
rlActivate, _ := strconv.Atoi(cfg.RateLimitActivate)
|
rlActivate, _ := strconv.Atoi(cfg.RateLimitActivate)
|
||||||
@ -74,7 +80,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runMigrations(db *sql.DB) {
|
func runMigrations(db *sql.DB) {
|
||||||
files := []string{"migrations/001_create_tables.sql", "migrations/002_seed_products.sql"}
|
files := []string{
|
||||||
|
"migrations/001_create_tables.sql",
|
||||||
|
"migrations/002_seed_products.sql",
|
||||||
|
"migrations/003_create_admin_users.sql",
|
||||||
|
}
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
data, err := os.ReadFile(f)
|
data, err := os.ReadFile(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -87,3 +97,31 @@ func runMigrations(db *sql.DB) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func seedDefaultAdmin(userRepo *repository.UserRepo, adminPassword string) {
|
||||||
|
count, err := userRepo.Count()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Seed admin: count error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Seed admin: bcrypt error: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &model.AdminUser{
|
||||||
|
Username: "admin",
|
||||||
|
PasswordHash: string(hash),
|
||||||
|
FullName: "Administrator",
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
if err := userRepo.Create(user); err != nil {
|
||||||
|
log.Printf("Seed admin: %v (may already exist)", err)
|
||||||
|
} else {
|
||||||
|
log.Println("Default admin korisnik kreiran (username: admin)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
7
go.mod
7
go.mod
@ -1,7 +1,10 @@
|
|||||||
module dal-license-server
|
module dal-license-server
|
||||||
|
|
||||||
go 1.23.6
|
go 1.24.0
|
||||||
|
|
||||||
require github.com/go-sql-driver/mysql v1.9.3
|
require github.com/go-sql-driver/mysql v1.9.3
|
||||||
|
|
||||||
require filippo.io/edwards25519 v1.1.0 // indirect
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
2
go.sum
2
go.sum
@ -2,3 +2,5 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
|||||||
@ -15,19 +15,28 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type SessionData struct {
|
||||||
|
UserID int64
|
||||||
|
Username string
|
||||||
|
FullName string
|
||||||
|
Expiry time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type DashboardHandler struct {
|
type DashboardHandler struct {
|
||||||
licenses *service.LicenseService
|
licenses *service.LicenseService
|
||||||
activation *service.ActivationService
|
activation *service.ActivationService
|
||||||
audit *repository.AuditRepo
|
audit *repository.AuditRepo
|
||||||
|
userRepo *repository.UserRepo
|
||||||
templates map[string]*template.Template
|
templates map[string]*template.Template
|
||||||
sessions map[string]time.Time
|
sessions map[string]*SessionData
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
password string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDashboardHandler(licenses *service.LicenseService, activation *service.ActivationService, audit *repository.AuditRepo, tmplDir, password string) *DashboardHandler {
|
func NewDashboardHandler(licenses *service.LicenseService, activation *service.ActivationService, audit *repository.AuditRepo, userRepo *repository.UserRepo, tmplDir string) *DashboardHandler {
|
||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"formatDate": func(t time.Time) string { return t.Format("02.01.2006 15:04") },
|
"formatDate": func(t time.Time) string { return t.Format("02.01.2006 15:04") },
|
||||||
"formatDateShort": func(t time.Time) string { return t.Format("02.01.2006") },
|
"formatDateShort": func(t time.Time) string { return t.Format("02.01.2006") },
|
||||||
@ -61,9 +70,9 @@ func NewDashboardHandler(licenses *service.LicenseService, activation *service.A
|
|||||||
licenses: licenses,
|
licenses: licenses,
|
||||||
activation: activation,
|
activation: activation,
|
||||||
audit: audit,
|
audit: audit,
|
||||||
|
userRepo: userRepo,
|
||||||
templates: make(map[string]*template.Template),
|
templates: make(map[string]*template.Template),
|
||||||
sessions: make(map[string]time.Time),
|
sessions: make(map[string]*SessionData),
|
||||||
password: password,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
layoutFiles, _ := filepath.Glob(filepath.Join(tmplDir, "layout", "*.html"))
|
layoutFiles, _ := filepath.Glob(filepath.Join(tmplDir, "layout", "*.html"))
|
||||||
@ -95,23 +104,33 @@ func (h *DashboardHandler) render(w http.ResponseWriter, name string, data inter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DashboardHandler) isLoggedIn(r *http.Request) bool {
|
func (h *DashboardHandler) renderWithSession(w http.ResponseWriter, r *http.Request, name string, data map[string]interface{}) {
|
||||||
|
if sess := h.getSession(r); sess != nil {
|
||||||
|
data["CurrentUser"] = sess.FullName
|
||||||
|
if data["CurrentUser"] == "" {
|
||||||
|
data["CurrentUser"] = sess.Username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.render(w, name, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DashboardHandler) getSession(r *http.Request) *SessionData {
|
||||||
c, err := r.Cookie("dash_session")
|
c, err := r.Cookie("dash_session")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return nil
|
||||||
}
|
}
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
exp, ok := h.sessions[c.Value]
|
sess, ok := h.sessions[c.Value]
|
||||||
if !ok || time.Now().After(exp) {
|
if !ok || time.Now().After(sess.Expiry) {
|
||||||
return false
|
return nil
|
||||||
}
|
}
|
||||||
return true
|
return sess
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DashboardHandler) RequireLogin(next http.Handler) http.Handler {
|
func (h *DashboardHandler) RequireLogin(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !h.isLoggedIn(r) {
|
if h.getSession(r) == nil {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -124,9 +143,17 @@ func (h *DashboardHandler) LoginPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DashboardHandler) Login(w http.ResponseWriter, r *http.Request) {
|
func (h *DashboardHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := r.FormValue("username")
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
if password != h.password {
|
|
||||||
h.render(w, "login.html", map[string]interface{}{"Error": "Pogresna lozinka"})
|
user, err := h.userRepo.GetByUsername(username)
|
||||||
|
if err != nil || !user.Active {
|
||||||
|
h.render(w, "login.html", map[string]interface{}{"Error": "Pogresno korisnicko ime ili lozinka"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||||
|
h.render(w, "login.html", map[string]interface{}{"Error": "Pogresno korisnicko ime ili lozinka"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,9 +162,17 @@ func (h *DashboardHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
sid := hex.EncodeToString(b)
|
sid := hex.EncodeToString(b)
|
||||||
|
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.sessions[sid] = time.Now().Add(8 * time.Hour)
|
h.sessions[sid] = &SessionData{
|
||||||
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
FullName: user.FullName,
|
||||||
|
Expiry: time.Now().Add(8 * time.Hour),
|
||||||
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
h.userRepo.UpdateLastLogin(user.ID)
|
||||||
|
h.audit.Log(nil, "LOGIN", clientIP(r), map[string]string{"username": user.Username})
|
||||||
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "dash_session",
|
Name: "dash_session",
|
||||||
Value: sid,
|
Value: sid,
|
||||||
@ -151,8 +186,12 @@ func (h *DashboardHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *DashboardHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
func (h *DashboardHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
if c, err := r.Cookie("dash_session"); err == nil {
|
if c, err := r.Cookie("dash_session"); err == nil {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
|
sess := h.sessions[c.Value]
|
||||||
delete(h.sessions, c.Value)
|
delete(h.sessions, c.Value)
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
if sess != nil {
|
||||||
|
h.audit.Log(nil, "LOGOUT", clientIP(r), map[string]string{"username": sess.Username})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
http.SetCookie(w, &http.Cookie{Name: "dash_session", MaxAge: -1, Path: "/"})
|
http.SetCookie(w, &http.Cookie{Name: "dash_session", MaxAge: -1, Path: "/"})
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
@ -163,7 +202,7 @@ func (h *DashboardHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
expiring, _ := h.licenses.ExpiringIn(7)
|
expiring, _ := h.licenses.ExpiringIn(7)
|
||||||
recent, _ := h.audit.Recent(10)
|
recent, _ := h.audit.Recent(10)
|
||||||
|
|
||||||
h.render(w, "dashboard.html", map[string]interface{}{
|
h.renderWithSession(w, r, "dashboard.html", map[string]interface{}{
|
||||||
"Stats": stats,
|
"Stats": stats,
|
||||||
"Expiring": expiring,
|
"Expiring": expiring,
|
||||||
"Recent": recent,
|
"Recent": recent,
|
||||||
@ -179,7 +218,7 @@ func (h *DashboardHandler) LicenseList(w http.ResponseWriter, r *http.Request) {
|
|||||||
licenses, _ := h.licenses.List(product, status, search)
|
licenses, _ := h.licenses.List(product, status, search)
|
||||||
products, _ := h.licenses.GetProducts()
|
products, _ := h.licenses.GetProducts()
|
||||||
|
|
||||||
h.render(w, "licenses.html", map[string]interface{}{
|
h.renderWithSession(w, r, "licenses.html", map[string]interface{}{
|
||||||
"Licenses": licenses,
|
"Licenses": licenses,
|
||||||
"Products": products,
|
"Products": products,
|
||||||
"Product": product,
|
"Product": product,
|
||||||
@ -191,7 +230,7 @@ func (h *DashboardHandler) LicenseList(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (h *DashboardHandler) LicenseNew(w http.ResponseWriter, r *http.Request) {
|
func (h *DashboardHandler) LicenseNew(w http.ResponseWriter, r *http.Request) {
|
||||||
products, _ := h.licenses.GetProducts()
|
products, _ := h.licenses.GetProducts()
|
||||||
h.render(w, "license-new.html", map[string]interface{}{
|
h.renderWithSession(w, r, "license-new.html", map[string]interface{}{
|
||||||
"Products": products,
|
"Products": products,
|
||||||
"ActivePage": "licenses",
|
"ActivePage": "licenses",
|
||||||
})
|
})
|
||||||
@ -231,7 +270,7 @@ func (h *DashboardHandler) LicenseCreate(w http.ResponseWriter, r *http.Request)
|
|||||||
license, err := h.licenses.Create(req, clientIP(r))
|
license, err := h.licenses.Create(req, clientIP(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
products, _ := h.licenses.GetProducts()
|
products, _ := h.licenses.GetProducts()
|
||||||
h.render(w, "license-new.html", map[string]interface{}{
|
h.renderWithSession(w, r, "license-new.html", map[string]interface{}{
|
||||||
"Products": products,
|
"Products": products,
|
||||||
"Error": err.Error(),
|
"Error": err.Error(),
|
||||||
"ActivePage": "licenses",
|
"ActivePage": "licenses",
|
||||||
@ -253,7 +292,7 @@ func (h *DashboardHandler) LicenseDetail(w http.ResponseWriter, r *http.Request)
|
|||||||
activations, _ := h.activation.ListByLicense(id)
|
activations, _ := h.activation.ListByLicense(id)
|
||||||
auditEntries, _ := h.audit.List(&id, 20)
|
auditEntries, _ := h.audit.List(&id, 20)
|
||||||
|
|
||||||
h.render(w, "license-detail.html", map[string]interface{}{
|
h.renderWithSession(w, r, "license-detail.html", map[string]interface{}{
|
||||||
"License": license,
|
"License": license,
|
||||||
"Activations": activations,
|
"Activations": activations,
|
||||||
"Audit": auditEntries,
|
"Audit": auditEntries,
|
||||||
@ -276,7 +315,7 @@ func (h *DashboardHandler) LicenseRelease(w http.ResponseWriter, r *http.Request
|
|||||||
|
|
||||||
func (h *DashboardHandler) AuditPage(w http.ResponseWriter, r *http.Request) {
|
func (h *DashboardHandler) AuditPage(w http.ResponseWriter, r *http.Request) {
|
||||||
entries, _ := h.audit.Recent(100)
|
entries, _ := h.audit.Recent(100)
|
||||||
h.render(w, "audit.html", map[string]interface{}{
|
h.renderWithSession(w, r, "audit.html", map[string]interface{}{
|
||||||
"Entries": entries,
|
"Entries": entries,
|
||||||
"ActivePage": "audit",
|
"ActivePage": "audit",
|
||||||
})
|
})
|
||||||
|
|||||||
17
internal/model/user.go
Normal file
17
internal/model/user.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminUser struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
PasswordHash string `json:"-"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
LastLoginAt sql.NullTime `json:"last_login_at"`
|
||||||
|
}
|
||||||
53
internal/repository/user_repo.go
Normal file
53
internal/repository/user_repo.go
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"dal-license-server/internal/model"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserRepo struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserRepo(db *sql.DB) *UserRepo {
|
||||||
|
return &UserRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) GetByUsername(username string) (*model.AdminUser, error) {
|
||||||
|
var u model.AdminUser
|
||||||
|
err := r.db.QueryRow(
|
||||||
|
"SELECT id, username, password_hash, full_name, active, created_at, updated_at, last_login_at FROM admin_users WHERE username = ?",
|
||||||
|
username,
|
||||||
|
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.FullName, &u.Active, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get user by username: %w", err)
|
||||||
|
}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) Create(u *model.AdminUser) error {
|
||||||
|
res, err := r.db.Exec(
|
||||||
|
"INSERT INTO admin_users (username, password_hash, full_name, active) VALUES (?, ?, ?, ?)",
|
||||||
|
u.Username, u.PasswordHash, u.FullName, u.Active,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create user: %w", err)
|
||||||
|
}
|
||||||
|
u.ID, _ = res.LastInsertId()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) UpdateLastLogin(id int64) error {
|
||||||
|
_, err := r.db.Exec("UPDATE admin_users SET last_login_at = NOW() WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update last login: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) Count() (int, error) {
|
||||||
|
var count int
|
||||||
|
err := r.db.QueryRow("SELECT COUNT(*) FROM admin_users").Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
10
migrations/003_create_admin_users.sql
Normal file
10
migrations/003_create_admin_users.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS admin_users (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
full_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
last_login_at TIMESTAMP NULL
|
||||||
|
);
|
||||||
@ -2,7 +2,8 @@
|
|||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||||
.container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
|
.container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
|
||||||
.navbar { background: #1a1a2e; color: #fff; padding: 0.75rem 2rem; display: flex; align-items: center; gap: 2rem; }
|
.navbar { background: #1a1a2e; color: #fff; padding: 0.75rem 2rem; display: flex; align-items: center; gap: 2rem; }
|
||||||
.nav-brand { font-size: 1.2rem; font-weight: 700; }
|
.nav-brand { font-size: 1.2rem; font-weight: 700; display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.nav-logo { width: 28px; height: 28px; }
|
||||||
.nav-links { display: flex; gap: 1rem; flex: 1; }
|
.nav-links { display: flex; gap: 1rem; flex: 1; }
|
||||||
.nav-links a { color: #aaa; text-decoration: none; padding: 0.5rem 1rem; border-radius: 4px; }
|
.nav-links a { color: #aaa; text-decoration: none; padding: 0.5rem 1rem; border-radius: 4px; }
|
||||||
.nav-links a:hover, .nav-links a.active { color: #fff; background: rgba(255,255,255,0.1); }
|
.nav-links a:hover, .nav-links a.active { color: #fff; background: rgba(255,255,255,0.1); }
|
||||||
@ -48,7 +49,8 @@ code { background: #f1f5f9; padding: 0.15rem 0.5rem; border-radius: 3px; font-si
|
|||||||
.stat-row { display: flex; justify-content: space-between; padding: 0.2rem 0; font-size: 0.9rem; }
|
.stat-row { display: flex; justify-content: space-between; padding: 0.2rem 0; font-size: 0.9rem; }
|
||||||
|
|
||||||
.login-container { max-width: 400px; margin: 100px auto; padding: 2rem; }
|
.login-container { max-width: 400px; margin: 100px auto; padding: 2rem; }
|
||||||
.login-container h1 { text-align: center; margin-bottom: 2rem; }
|
.login-container h1 { text-align: center; margin-bottom: 2rem; display: flex; align-items: center; justify-content: center; gap: 0.6rem; }
|
||||||
|
.login-logo { width: 32px; height: 32px; }
|
||||||
.login-form { background: #fff; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
.login-form { background: #fff; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||||
.form-group { margin-bottom: 1rem; }
|
.form-group { margin-bottom: 1rem; }
|
||||||
.form-group label { display: block; margin-bottom: 0.4rem; font-weight: 600; font-size: 0.9rem; }
|
.form-group label { display: block; margin-bottom: 0.4rem; font-weight: 600; font-size: 0.9rem; }
|
||||||
|
|||||||
7
static/img/favicon.svg
Normal file
7
static/img/favicon.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||||
|
<path d="M16 2L4 8v8c0 7.2 5.12 13.92 12 16 6.88-2.08 12-8.8 12-16V8L16 2z" fill="#1a1a2e"/>
|
||||||
|
<path d="M16 5L7 9.5v6.5c0 5.8 4 11.2 9 13 5-1.8 9-7.2 9-13V9.5L16 5z" fill="#2563eb"/>
|
||||||
|
<circle cx="16" cy="13" r="3.5" fill="none" stroke="#fff" stroke-width="1.8"/>
|
||||||
|
<rect x="15" y="16.5" width="2" height="7" rx="1" fill="#fff"/>
|
||||||
|
<rect x="17" y="19.5" width="3" height="1.5" rx=".75" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 482 B |
10
static/img/logo.svg
Normal file
10
static/img/logo.svg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<!-- Shield -->
|
||||||
|
<path d="M32 4L8 16v16c0 14.4 10.24 27.84 24 32 13.76-4.16 24-17.6 24-32V16L32 4z" fill="#1a1a2e"/>
|
||||||
|
<path d="M32 8L12 18v14c0 12.4 8.8 24 20 27.6C43.2 56 52 44.4 52 32V18L32 8z" fill="#2563eb"/>
|
||||||
|
<!-- Key -->
|
||||||
|
<circle cx="32" cy="26" r="7" fill="none" stroke="#fff" stroke-width="2.5"/>
|
||||||
|
<rect x="30.5" y="33" width="3" height="12" rx="1.5" fill="#fff"/>
|
||||||
|
<rect x="33.5" y="39" width="5" height="2.5" rx="1" fill="#fff"/>
|
||||||
|
<rect x="33.5" y="43" width="3.5" height="2.5" rx="1" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 598 B |
@ -4,18 +4,20 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{block "page-title" .}}DAL License Server{{end}}</title>
|
<title>{{block "page-title" .}}DAL License Server{{end}}</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg">
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
<script src="/static/js/htmx.min.js"></script>
|
<script src="/static/js/htmx.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar">
|
<nav class="navbar">
|
||||||
<div class="nav-brand">DAL License Server</div>
|
<div class="nav-brand"><img src="/static/img/logo.svg" alt="" class="nav-logo">DAL License Server</div>
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="/dashboard" class="{{if eq .ActivePage "dashboard"}}active{{end}}">Dashboard</a>
|
<a href="/dashboard" class="{{if eq .ActivePage "dashboard"}}active{{end}}">Dashboard</a>
|
||||||
<a href="/licenses" class="{{if eq .ActivePage "licenses"}}active{{end}}">Licence</a>
|
<a href="/licenses" class="{{if eq .ActivePage "licenses"}}active{{end}}">Licence</a>
|
||||||
<a href="/audit" class="{{if eq .ActivePage "audit"}}active{{end}}">Audit Log</a>
|
<a href="/audit" class="{{if eq .ActivePage "audit"}}active{{end}}">Audit Log</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-user">
|
<div class="nav-user">
|
||||||
|
{{if .CurrentUser}}<span class="nav-username">{{.CurrentUser}}</span>{{end}}
|
||||||
<form method="POST" action="/logout" style="display:inline">
|
<form method="POST" action="/logout" style="display:inline">
|
||||||
<button type="submit" class="btn btn-sm">Odjava</button>
|
<button type="submit" class="btn btn-sm">Odjava</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -4,16 +4,21 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Prijava - DAL License Server</title>
|
<title>Prijava - DAL License Server</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg">
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="login-container">
|
<div class="login-container">
|
||||||
<h1>DAL License Server</h1>
|
<h1><img src="/static/img/logo.svg" alt="" class="login-logo">DAL License Server</h1>
|
||||||
<form method="POST" action="/login" class="login-form">
|
<form method="POST" action="/login" class="login-form">
|
||||||
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Korisnicko ime</label>
|
||||||
|
<input type="text" name="username" autofocus required>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Lozinka</label>
|
<label>Lozinka</label>
|
||||||
<input type="password" name="password" autofocus required>
|
<input type="password" name="password" required>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-full">Prijava</button>
|
<button type="submit" class="btn btn-primary btn-full">Prijava</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ const API_KEY = 'dev-api-key-minimum-32-characters-long';
|
|||||||
|
|
||||||
async function login(page: Page) {
|
async function login(page: Page) {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
await page.fill('input[name="username"]', 'admin');
|
||||||
await page.fill('input[name="password"]', 'admin123');
|
await page.fill('input[name="password"]', 'admin123');
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { test, expect, Page } from '@playwright/test';
|
|||||||
|
|
||||||
async function login(page: Page) {
|
async function login(page: Page) {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
await page.fill('input[name="username"]', 'admin');
|
||||||
await page.fill('input[name="password"]', 'admin123');
|
await page.fill('input[name="password"]', 'admin123');
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { test, expect, Page } from '@playwright/test';
|
|||||||
|
|
||||||
async function login(page: Page) {
|
async function login(page: Page) {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
await page.fill('input[name="username"]', 'admin');
|
||||||
await page.fill('input[name="password"]', 'admin123');
|
await page.fill('input[name="password"]', 'admin123');
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { test, expect, Page } from '@playwright/test';
|
|||||||
|
|
||||||
async function login(page: Page) {
|
async function login(page: Page) {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
await page.fill('input[name="username"]', 'admin');
|
||||||
await page.fill('input[name="password"]', 'admin123');
|
await page.fill('input[name="password"]', 'admin123');
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { test, expect, Page } from '@playwright/test';
|
|||||||
|
|
||||||
async function login(page: Page) {
|
async function login(page: Page) {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
await page.fill('input[name="username"]', 'admin');
|
||||||
await page.fill('input[name="password"]', 'admin123');
|
await page.fill('input[name="password"]', 'admin123');
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
|
|||||||
@ -4,16 +4,17 @@ test.describe('Login stranica', () => {
|
|||||||
test('prikazuje login formu sa svim elementima', async ({ page }) => {
|
test('prikazuje login formu sa svim elementima', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await expect(page.locator('h1')).toHaveText('DAL License Server');
|
await expect(page.locator('h1')).toHaveText('DAL License Server');
|
||||||
|
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||||
|
await expect(page.locator('input[name="username"]')).toHaveAttribute('required', '');
|
||||||
await expect(page.locator('input[name="password"]')).toBeVisible();
|
await expect(page.locator('input[name="password"]')).toBeVisible();
|
||||||
await expect(page.locator('input[name="password"]')).toHaveAttribute('type', 'password');
|
await expect(page.locator('input[name="password"]')).toHaveAttribute('type', 'password');
|
||||||
await expect(page.locator('input[name="password"]')).toHaveAttribute('required', '');
|
await expect(page.locator('input[name="password"]')).toHaveAttribute('required', '');
|
||||||
await expect(page.locator('button[type="submit"]')).toHaveText('Prijava');
|
await expect(page.locator('button[type="submit"]')).toHaveText('Prijava');
|
||||||
await expect(page.locator('label')).toHaveText('Lozinka');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('password polje ima autofocus', async ({ page }) => {
|
test('username polje ima autofocus', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await expect(page.locator('input[name="password"]')).toHaveAttribute('autofocus', '');
|
await expect(page.locator('input[name="username"]')).toHaveAttribute('autofocus', '');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('forma ima ispravnu action i method', async ({ page }) => {
|
test('forma ima ispravnu action i method', async ({ page }) => {
|
||||||
@ -23,26 +24,36 @@ test.describe('Login stranica', () => {
|
|||||||
await expect(form).toHaveAttribute('action', '/login');
|
await expect(form).toHaveAttribute('action', '/login');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prijava sa ispravnom lozinkom preusmerava na dashboard', async ({ page }) => {
|
test('prijava sa ispravnim username i password preusmerava na dashboard', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
await page.fill('input[name="username"]', 'admin');
|
||||||
await page.fill('input[name="password"]', 'admin123');
|
await page.fill('input[name="password"]', 'admin123');
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
await expect(page.locator('h1')).toHaveText('Dashboard');
|
await expect(page.locator('h1')).toHaveText('Dashboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('prijava sa pogresnim username-om prikazuje gresku', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.fill('input[name="username"]', 'nepostojeci_user');
|
||||||
|
await page.fill('input[name="password"]', 'admin123');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await expect(page.locator('.alert-error')).toHaveText('Pogresno korisnicko ime ili lozinka');
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
|
||||||
test('prijava sa pogresnom lozinkom prikazuje gresku', async ({ page }) => {
|
test('prijava sa pogresnom lozinkom prikazuje gresku', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
await page.fill('input[name="username"]', 'admin');
|
||||||
await page.fill('input[name="password"]', 'pogresna_lozinka');
|
await page.fill('input[name="password"]', 'pogresna_lozinka');
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page.locator('.alert-error')).toHaveText('Pogresna lozinka');
|
await expect(page.locator('.alert-error')).toHaveText('Pogresno korisnicko ime ili lozinka');
|
||||||
await expect(page).toHaveURL(/\/login/);
|
await expect(page).toHaveURL(/\/login/);
|
||||||
// Password polje treba biti prazno posle greske
|
|
||||||
await expect(page.locator('input[name="password"]')).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prijava sa praznom lozinkom — browser validacija', async ({ page }) => {
|
test('prijava sa praznom lozinkom — browser validacija', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
await page.fill('input[name="username"]', 'admin');
|
||||||
// Ne popunjavamo password — klik na submit
|
// Ne popunjavamo password — klik na submit
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
// Ostajemo na login stranici (browser validation spreci submit)
|
// Ostajemo na login stranici (browser validation spreci submit)
|
||||||
@ -54,6 +65,7 @@ test.describe('Login stranica', () => {
|
|||||||
for (const lozinka of pogresne) {
|
for (const lozinka of pogresne) {
|
||||||
if (lozinka === '') continue; // skip empty — browser validation
|
if (lozinka === '') continue; // skip empty — browser validation
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
await page.fill('input[name="username"]', 'admin');
|
||||||
await page.fill('input[name="password"]', lozinka);
|
await page.fill('input[name="password"]', lozinka);
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/login/);
|
await expect(page).toHaveURL(/\/login/);
|
||||||
@ -99,6 +111,7 @@ test.describe('Login stranica', () => {
|
|||||||
test('visestruki logini kreiraju razlicite sesije', async ({ page, context }) => {
|
test('visestruki logini kreiraju razlicite sesije', async ({ page, context }) => {
|
||||||
// Login prvi put
|
// Login prvi put
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
await page.fill('input[name="username"]', 'admin');
|
||||||
await page.fill('input[name="password"]', 'admin123');
|
await page.fill('input[name="password"]', 'admin123');
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
@ -111,6 +124,7 @@ test.describe('Login stranica', () => {
|
|||||||
await page.click('button:has-text("Odjava")');
|
await page.click('button:has-text("Odjava")');
|
||||||
|
|
||||||
// Login drugi put
|
// Login drugi put
|
||||||
|
await page.fill('input[name="username"]', 'admin');
|
||||||
await page.fill('input[name="password"]', 'admin123');
|
await page.fill('input[name="password"]', 'admin123');
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
@ -121,4 +135,22 @@ test.describe('Login stranica', () => {
|
|||||||
// Sesije moraju biti razlicite
|
// Sesije moraju biti razlicite
|
||||||
expect(session1!.value).not.toBe(session2!.value);
|
expect(session1!.value).not.toBe(session2!.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('navbar prikazuje ime korisnika posle logina', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.fill('input[name="username"]', 'admin');
|
||||||
|
await page.fill('input[name="password"]', 'admin123');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
|
await expect(page.locator('.nav-username')).toBeVisible();
|
||||||
|
await expect(page.locator('.nav-username')).toHaveText('Administrator');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('username i password labele su prikazane', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
const labels = page.locator('label');
|
||||||
|
const texts = await labels.allTextContents();
|
||||||
|
expect(texts).toContain('Korisnicko ime');
|
||||||
|
expect(texts).toContain('Lozinka');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user