Compare commits

..

2 Commits

Author SHA1 Message Date
djuka
74d52e05c1 Merge: spoji dokumentaciju (asp-terminia/loggerservice) sa zatecenim produkcijskim radom 2026-07-25 05:47:59 +00:00
djuka
04fd1678be Zateceno stanje sa produkcije: admin_users prijava + logo/favicon
Ovaj commit hvata radnu kopiju sa 151 kakva je zatecena 2026-07-25 (nije
pravljena u ovoj sesiji) - funkcionalnost prijave administratora preko
admin_users tabele (username/password_hash umesto jednog ADMIN_PASSWORD),
plus logo/favicon i go 1.24.0 + golang.org/x/crypto zavisnost. Binarni fajl
koji trenutno radi na produkciji je bas iz ovog stanja.

Zarobljeno kao osnova pre preimenovanja dal-license-server -> licence-server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 05:47:42 +00:00
19 changed files with 272 additions and 44 deletions

View File

@ -1,8 +1,8 @@
# DAL License Server — Test Checklista
## Ukupno testova: 179
## Ukupno testova: 182
- Go unit testovi: 46
- Playwright E2E testovi: 133
- Playwright E2E testovi: 136
## Pokretanje testova
@ -92,11 +92,12 @@ go test ./internal/... -v -count=1 && npx playwright test
## Playwright E2E Testovi (133)
### Login stranica (15 testova)
- [x] Prikazuje login formu sa svim elementima
- [x] Password polje ima autofocus
### Login stranica (18 testova)
- [x] Prikazuje login formu sa svim elementima (username + password)
- [x] Username polje ima autofocus
- [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 praznom lozinkom (browser validacija)
- [x] Razlicite pogresne lozinke (7 pokusaja)
@ -108,6 +109,8 @@ go test ./internal/... -v -count=1 && npx playwright test
- [x] CSS je ucitan
- [x] Ispravan page title
- [x] Visestruki logini kreiraju razlicite sesije
- [x] Navbar prikazuje ime korisnika posle logina
- [x] Username i password labele su prikazane
### Dashboard stranica (18 testova)
- [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)*

View File

@ -3,6 +3,7 @@ package main
import (
"dal-license-server/internal/config"
"dal-license-server/internal/handler"
"dal-license-server/internal/model"
"dal-license-server/internal/repository"
"dal-license-server/internal/router"
"dal-license-server/internal/service"
@ -14,6 +15,7 @@ import (
"strconv"
_ "github.com/go-sql-driver/mysql"
"golang.org/x/crypto/bcrypt"
)
func main() {
@ -43,6 +45,10 @@ func main() {
licenseRepo := repository.NewLicenseRepo(db)
activationRepo := repository.NewActivationRepo(db)
auditRepo := repository.NewAuditRepo(db)
userRepo := repository.NewUserRepo(db)
// Seed default admin user
seedDefaultAdmin(userRepo, cfg.AdminPassword)
// Services
licenseSvc := service.NewLicenseService(licenseRepo, auditRepo)
@ -51,7 +57,7 @@ func main() {
// Handlers
clientHandler := handler.NewClientHandler(activationSvc)
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
rlActivate, _ := strconv.Atoi(cfg.RateLimitActivate)
@ -74,7 +80,11 @@ func main() {
}
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 {
data, err := os.ReadFile(f)
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
View File

@ -1,7 +1,10 @@
module dal-license-server
go 1.23.6
go 1.24.0
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
View File

@ -2,3 +2,5 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
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/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=

View File

@ -15,19 +15,28 @@ import (
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
type SessionData struct {
UserID int64
Username string
FullName string
Expiry time.Time
}
type DashboardHandler struct {
licenses *service.LicenseService
activation *service.ActivationService
audit *repository.AuditRepo
userRepo *repository.UserRepo
templates map[string]*template.Template
sessions map[string]time.Time
sessions map[string]*SessionData
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{
"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") },
@ -61,9 +70,9 @@ func NewDashboardHandler(licenses *service.LicenseService, activation *service.A
licenses: licenses,
activation: activation,
audit: audit,
userRepo: userRepo,
templates: make(map[string]*template.Template),
sessions: make(map[string]time.Time),
password: password,
sessions: make(map[string]*SessionData),
}
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")
if err != nil {
return false
return nil
}
h.mu.RLock()
defer h.mu.RUnlock()
exp, ok := h.sessions[c.Value]
if !ok || time.Now().After(exp) {
return false
sess, ok := h.sessions[c.Value]
if !ok || time.Now().After(sess.Expiry) {
return nil
}
return true
return sess
}
func (h *DashboardHandler) RequireLogin(next http.Handler) http.Handler {
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)
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) {
username := r.FormValue("username")
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
}
@ -135,9 +162,17 @@ func (h *DashboardHandler) Login(w http.ResponseWriter, r *http.Request) {
sid := hex.EncodeToString(b)
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.userRepo.UpdateLastLogin(user.ID)
h.audit.Log(nil, "LOGIN", clientIP(r), map[string]string{"username": user.Username})
http.SetCookie(w, &http.Cookie{
Name: "dash_session",
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) {
if c, err := r.Cookie("dash_session"); err == nil {
h.mu.Lock()
sess := h.sessions[c.Value]
delete(h.sessions, c.Value)
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.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)
recent, _ := h.audit.Recent(10)
h.render(w, "dashboard.html", map[string]interface{}{
h.renderWithSession(w, r, "dashboard.html", map[string]interface{}{
"Stats": stats,
"Expiring": expiring,
"Recent": recent,
@ -179,7 +218,7 @@ func (h *DashboardHandler) LicenseList(w http.ResponseWriter, r *http.Request) {
licenses, _ := h.licenses.List(product, status, search)
products, _ := h.licenses.GetProducts()
h.render(w, "licenses.html", map[string]interface{}{
h.renderWithSession(w, r, "licenses.html", map[string]interface{}{
"Licenses": licenses,
"Products": products,
"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) {
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,
"ActivePage": "licenses",
})
@ -231,7 +270,7 @@ func (h *DashboardHandler) LicenseCreate(w http.ResponseWriter, r *http.Request)
license, err := h.licenses.Create(req, clientIP(r))
if err != nil {
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,
"Error": err.Error(),
"ActivePage": "licenses",
@ -253,7 +292,7 @@ func (h *DashboardHandler) LicenseDetail(w http.ResponseWriter, r *http.Request)
activations, _ := h.activation.ListByLicense(id)
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,
"Activations": activations,
"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) {
entries, _ := h.audit.Recent(100)
h.render(w, "audit.html", map[string]interface{}{
h.renderWithSession(w, r, "audit.html", map[string]interface{}{
"Entries": entries,
"ActivePage": "audit",
})

17
internal/model/user.go Normal file
View 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"`
}

View 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
}

View 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
);

View File

@ -2,7 +2,8 @@
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
.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; }
.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 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); }
@ -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; }
.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); }
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.4rem; font-weight: 600; font-size: 0.9rem; }

7
static/img/favicon.svg Normal file
View 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
View 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

View File

@ -4,18 +4,20 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
<script src="/static/js/htmx.min.js"></script>
</head>
<body>
<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">
<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="/audit" class="{{if eq .ActivePage "audit"}}active{{end}}">Audit Log</a>
</div>
<div class="nav-user">
{{if .CurrentUser}}<span class="nav-username">{{.CurrentUser}}</span>{{end}}
<form method="POST" action="/logout" style="display:inline">
<button type="submit" class="btn btn-sm">Odjava</button>
</form>

View File

@ -4,16 +4,21 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
</head>
<body>
<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">
{{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">
<label>Lozinka</label>
<input type="password" name="password" autofocus required>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary btn-full">Prijava</button>
</form>

View File

@ -5,6 +5,7 @@ const API_KEY = 'dev-api-key-minimum-32-characters-long';
async function login(page: 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/);

View File

@ -2,6 +2,7 @@ import { test, expect, Page } from '@playwright/test';
async function login(page: 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/);

View File

@ -2,6 +2,7 @@ import { test, expect, Page } from '@playwright/test';
async function login(page: 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/);

View File

@ -2,6 +2,7 @@ import { test, expect, Page } from '@playwright/test';
async function login(page: 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/);

View File

@ -2,6 +2,7 @@ import { test, expect, Page } from '@playwright/test';
async function login(page: 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/);

View File

@ -4,16 +4,17 @@ test.describe('Login stranica', () => {
test('prikazuje login formu sa svim elementima', async ({ page }) => {
await page.goto('/login');
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"]')).toHaveAttribute('type', 'password');
await expect(page.locator('input[name="password"]')).toHaveAttribute('required', '');
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 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 }) => {
@ -23,26 +24,36 @@ test.describe('Login stranica', () => {
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.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('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 }) => {
await page.goto('/login');
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="password"]', 'pogresna_lozinka');
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/);
// Password polje treba biti prazno posle greske
await expect(page.locator('input[name="password"]')).toBeVisible();
});
test('prijava sa praznom lozinkom — browser validacija', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="username"]', 'admin');
// Ne popunjavamo password — klik na submit
await page.click('button[type="submit"]');
// Ostajemo na login stranici (browser validation spreci submit)
@ -54,6 +65,7 @@ test.describe('Login stranica', () => {
for (const lozinka of pogresne) {
if (lozinka === '') continue; // skip empty — browser validation
await page.goto('/login');
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="password"]', lozinka);
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/login/);
@ -99,6 +111,7 @@ test.describe('Login stranica', () => {
test('visestruki logini kreiraju razlicite sesije', async ({ page, context }) => {
// Login prvi put
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/);
@ -111,6 +124,7 @@ test.describe('Login stranica', () => {
await page.click('button:has-text("Odjava")');
// Login drugi put
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="password"]', 'admin123');
await page.click('button[type="submit"]');
@ -121,4 +135,22 @@ test.describe('Login stranica', () => {
// Sesije moraju biti razlicite
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');
});
});