Dodato kreiranje projekta kroz UI i change-password template
All checks were successful
Tests / unit-tests (push) Successful in 25s

- CreateProject() sa validacijom imena (regex, duplikati, prazno)
- POST /projects/create ruta sa AuthMiddleware
- Modal forma na projects stranici za unos imena
- 10 unit testova za CreateProject
- change-password.html template i testovi
- Ažuriran TESTING.md sa novom sekcijom

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
djuka 2026-02-18 06:27:10 +00:00
parent 3bb8d289af
commit 6c0ca3a96f
9 changed files with 589 additions and 1 deletions

View File

@ -14,6 +14,15 @@
- [ ] Projekat bez README.md prikazuje "Bez opisa"
- [ ] Klik na projekat → otvara chat
## Kreiranje projekta
- [ ] Klik na "Novi projekat" → pojavi se modal sa formom
- [ ] Unos validnog imena → projekat kreiran, vidljiv u listi
- [ ] Unos duplog imena → error poruka "već postoji"
- [ ] Unos praznog imena → browser validacija (required)
- [ ] Unos specijalnih karaktera → error poruka
- [ ] Klik van modala → modal se zatvara
- [ ] Klik na "Otkaži" → modal se zatvara
## Chat
- [ ] Chat stranica se otvori sa WebSocket konekcijom
- [ ] Pošalji poruku → prikazuje se user poruka
@ -23,6 +32,18 @@
- [ ] Enter → šalje poruku, Shift+Enter → novi red
- [ ] Input se čisti posle slanja
## Promena lozinke
- [ ] Pristup /change-password bez logina → redirect na /login
- [ ] Prikaz forme sa 3 polja (trenutna, nova, potvrda)
- [ ] Pogrešna trenutna lozinka → error poruka
- [ ] Nova lozinka kraća od 6 karaktera → error poruka
- [ ] Nova lozinka i potvrda se ne poklapaju → error poruka
- [ ] Uspešna promena → success poruka
- [ ] Login sa novom lozinkom → uspešan
- [ ] Login sa starom lozinkom → neuspešan
- [ ] Link "Promeni lozinku" vidljiv na /projects stranici
- [ ] Link "Nazad na projekte" vraća na /projects
## Sesija persistence
- [ ] Zatvori tab → otvori ponovo → sesija živa, poruke replayed
- [ ] Idle sesija se čisti posle 30 minuta

258
change_password_test.go Normal file
View File

@ -0,0 +1,258 @@
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
func setupTestApp(t *testing.T) (cleanupFn func()) {
t.Helper()
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
data := `{
"username": "admin",
"password": "secret123",
"session_secret": "test-secret",
"projects_path": "/tmp"
}`
os.WriteFile(cfgPath, []byte(data), 0644)
var err error
oldCfg := cfg
oldTemplates := templates
oldSessionMgr := sessionMgr
cfg, err = LoadConfig(cfgPath)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
templates, err = NewTemplateRenderer("templates")
if err != nil {
t.Fatalf("Templates: %v", err)
}
sessionMgr = NewSessionManager(cfg.SessionSecret)
return func() {
cfg = oldCfg
templates = oldTemplates
sessionMgr = oldSessionMgr
}
}
func TestChangePasswordPage(t *testing.T) {
cleanup := setupTestApp(t)
defer cleanup()
sess := sessionMgr.Create("admin")
req := httptest.NewRequest("GET", "/change-password", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sess.Token})
w := httptest.NewRecorder()
handleChangePasswordPage(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
body := w.Body.String()
if !strings.Contains(body, "Promena lozinke") {
t.Error("expected page title in response")
}
if !strings.Contains(body, "current_password") {
t.Error("expected current_password field")
}
}
func TestChangePassword(t *testing.T) {
t.Run("uspešna promena", func(t *testing.T) {
cleanup := setupTestApp(t)
defer cleanup()
form := url.Values{
"current_password": {"secret123"},
"new_password": {"newpass123"},
"confirm_password": {"newpass123"},
}
req := httptest.NewRequest("POST", "/change-password", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
handleChangePassword(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
body := w.Body.String()
if !strings.Contains(body, "uspešno promenjena") {
t.Error("expected success message")
}
// Nova šifra radi
if !cfg.CheckPassword("newpass123") {
t.Error("new password should work")
}
// Stara ne radi
if cfg.CheckPassword("secret123") {
t.Error("old password should not work")
}
})
t.Run("pogrešna trenutna lozinka", func(t *testing.T) {
cleanup := setupTestApp(t)
defer cleanup()
form := url.Values{
"current_password": {"wrongpass"},
"new_password": {"newpass123"},
"confirm_password": {"newpass123"},
}
req := httptest.NewRequest("POST", "/change-password", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
handleChangePassword(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
body := w.Body.String()
if !strings.Contains(body, "Pogrešna trenutna lozinka") {
t.Error("expected wrong password error message")
}
})
t.Run("prekratka nova lozinka", func(t *testing.T) {
cleanup := setupTestApp(t)
defer cleanup()
form := url.Values{
"current_password": {"secret123"},
"new_password": {"abc"},
"confirm_password": {"abc"},
}
req := httptest.NewRequest("POST", "/change-password", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
handleChangePassword(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
body := w.Body.String()
if !strings.Contains(body, "najmanje 6 karaktera") {
t.Error("expected min length error message")
}
})
t.Run("lozinke se ne poklapaju", func(t *testing.T) {
cleanup := setupTestApp(t)
defer cleanup()
form := url.Values{
"current_password": {"secret123"},
"new_password": {"newpass123"},
"confirm_password": {"different"},
}
req := httptest.NewRequest("POST", "/change-password", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
handleChangePassword(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
body := w.Body.String()
if !strings.Contains(body, "ne poklapaju") {
t.Error("expected mismatch error message")
}
})
}
func TestConfigCheckPassword(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
data := `{
"username": "admin",
"password": "testpass",
"session_secret": "abc123"
}`
os.WriteFile(cfgPath, []byte(data), 0644)
c, err := LoadConfig(cfgPath)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
// Šifra je hešovana u fajlu
if !isHashed(c.Password) {
t.Error("password should be hashed after LoadConfig")
}
// CheckPassword radi
if !c.CheckPassword("testpass") {
t.Error("CheckPassword should return true for correct password")
}
if c.CheckPassword("wrong") {
t.Error("CheckPassword should return false for wrong password")
}
}
func TestConfigSetPassword(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
data := `{
"username": "admin",
"password": "original",
"session_secret": "abc123"
}`
os.WriteFile(cfgPath, []byte(data), 0644)
c, err := LoadConfig(cfgPath)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if err := c.SetPassword("newpassword"); err != nil {
t.Fatalf("SetPassword: %v", err)
}
if !c.CheckPassword("newpassword") {
t.Error("new password should work")
}
if c.CheckPassword("original") {
t.Error("old password should not work")
}
// Proveri da je sačuvano u fajl
c2, err := LoadConfig(cfgPath)
if err != nil {
t.Fatalf("LoadConfig after SetPassword: %v", err)
}
if !c2.CheckPassword("newpassword") {
t.Error("password should persist after reload")
}
}
func TestIsHashed(t *testing.T) {
if isHashed("plaintext") {
t.Error("plaintext should not be detected as hashed")
}
if !isHashed("$2a$10$abcdefghijklmnopqrstuuABC") {
t.Error("$2a$ prefix should be detected as hashed")
}
if !isHashed("$2b$10$abcdefghijklmnopqrstuuABC") {
t.Error("$2b$ prefix should be detected as hashed")
}
}

View File

@ -34,6 +34,54 @@ func TestLoadConfig(t *testing.T) {
if cfg.ProjectsPath != "/tmp/projects" {
t.Errorf("projects_path = %q, want /tmp/projects", cfg.ProjectsPath)
}
if !cfg.CheckPassword("secret") {
t.Error("CheckPassword should return true for original password")
}
})
t.Run("password hashed on load", func(t *testing.T) {
data := `{
"username": "admin",
"password": "myplainpass",
"session_secret": "abc123"
}`
os.WriteFile(cfgPath, []byte(data), 0644)
cfg, err := LoadConfig(cfgPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !isHashed(cfg.Password) {
t.Error("password should be hashed after load")
}
if !cfg.CheckPassword("myplainpass") {
t.Error("CheckPassword should work with original password")
}
})
t.Run("already hashed password not re-hashed", func(t *testing.T) {
// Prvo učitaj da se hešuje
data := `{
"username": "admin",
"password": "testpass",
"session_secret": "abc123"
}`
os.WriteFile(cfgPath, []byte(data), 0644)
cfg1, err := LoadConfig(cfgPath)
if err != nil {
t.Fatalf("first load: %v", err)
}
hash1 := cfg1.Password
// Ponovo učitaj — heš treba da ostane isti
cfg2, err := LoadConfig(cfgPath)
if err != nil {
t.Fatalf("second load: %v", err)
}
if cfg2.Password != hash1 {
t.Error("already hashed password should not be re-hashed")
}
})
t.Run("defaults applied", func(t *testing.T) {

19
main.go
View File

@ -46,6 +46,7 @@ func main() {
// Protected routes
mux.Handle("GET /projects", AuthMiddleware(sessionMgr, http.HandlerFunc(handleProjects)))
mux.Handle("POST /projects/create", AuthMiddleware(sessionMgr, http.HandlerFunc(handleCreateProject)))
mux.Handle("GET /chat/{project}", AuthMiddleware(sessionMgr, http.HandlerFunc(handleChat)))
mux.Handle("GET /ws", AuthMiddleware(sessionMgr, wsHandler))
mux.Handle("GET /api/file", AuthMiddleware(sessionMgr, http.HandlerFunc(handleFileAPI)))
@ -111,6 +112,24 @@ func handleProjects(w http.ResponseWriter, r *http.Request) {
templates.Render(w, "projects.html", data)
}
func handleCreateProject(w http.ResponseWriter, r *http.Request) {
name := r.FormValue("name")
if err := CreateProject(cfg.ProjectsPath, name); err != nil {
projects, _ := ListProjects(cfg.ProjectsPath)
data := map[string]any{
"Projects": projects,
"ProjectsPath": cfg.ProjectsPath,
"Error": err.Error(),
}
w.WriteHeader(http.StatusBadRequest)
templates.Render(w, "projects.html", data)
return
}
http.Redirect(w, r, "/projects", http.StatusSeeOther)
}
func handleChat(w http.ResponseWriter, r *http.Request) {
project := r.PathValue("project")
if project == "" {

View File

@ -1,8 +1,10 @@
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
@ -62,3 +64,25 @@ func ListProjects(projectsPath string) ([]Project, error) {
return projects, nil
}
var validProjectName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
// CreateProject creates a new empty project directory.
// Name must contain only alphanumeric characters, hyphens, and underscores.
func CreateProject(projectsPath, name string) error {
if name == "" {
return fmt.Errorf("ime projekta ne može biti prazno")
}
if !validProjectName.MatchString(name) {
return fmt.Errorf("ime projekta može sadržati samo slova, brojeve, '-' i '_'")
}
path := filepath.Join(projectsPath, name)
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("projekat '%s' već postoji", name)
}
return os.MkdirAll(path, 0755)
}

View File

@ -79,6 +79,18 @@ func TestListProjects(t *testing.T) {
}
})
t.Run("excludes files", func(t *testing.T) {
projects, err := ListProjects(dir)
if err != nil {
t.Fatalf("ListProjects: %v", err)
}
for _, p := range projects {
if p.Name == "file.txt" {
t.Error("should not include files")
}
}
})
t.Run("nonexistent directory", func(t *testing.T) {
_, err := ListProjects("/nonexistent/path")
if err == nil {
@ -97,3 +109,99 @@ func TestListProjects(t *testing.T) {
}
})
}
func TestCreateProject(t *testing.T) {
t.Run("valid name creates directory", func(t *testing.T) {
dir := t.TempDir()
err := CreateProject(dir, "my-project")
if err != nil {
t.Fatalf("CreateProject: %v", err)
}
info, err := os.Stat(filepath.Join(dir, "my-project"))
if err != nil {
t.Fatalf("directory not created: %v", err)
}
if !info.IsDir() {
t.Error("expected directory, got file")
}
})
t.Run("valid name with underscore", func(t *testing.T) {
dir := t.TempDir()
err := CreateProject(dir, "my_project_2")
if err != nil {
t.Fatalf("CreateProject: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "my_project_2")); err != nil {
t.Fatalf("directory not created: %v", err)
}
})
t.Run("duplicate name returns error", func(t *testing.T) {
dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, "existing"), 0755)
err := CreateProject(dir, "existing")
if err == nil {
t.Fatal("expected error for duplicate name")
}
})
t.Run("empty name returns error", func(t *testing.T) {
dir := t.TempDir()
err := CreateProject(dir, "")
if err == nil {
t.Fatal("expected error for empty name")
}
})
t.Run("dot returns error", func(t *testing.T) {
dir := t.TempDir()
err := CreateProject(dir, ".")
if err == nil {
t.Fatal("expected error for '.'")
}
})
t.Run("dotdot returns error", func(t *testing.T) {
dir := t.TempDir()
err := CreateProject(dir, "..")
if err == nil {
t.Fatal("expected error for '..'")
}
})
t.Run("hidden name returns error", func(t *testing.T) {
dir := t.TempDir()
err := CreateProject(dir, ".secret")
if err == nil {
t.Fatal("expected error for hidden name")
}
})
t.Run("special characters return error", func(t *testing.T) {
dir := t.TempDir()
invalid := []string{"foo bar", "foo/bar", "foo..bar", "a@b", "a!b", "проект"}
for _, name := range invalid {
err := CreateProject(dir, name)
if err == nil {
t.Errorf("expected error for name %q", name)
}
}
})
t.Run("name starting with hyphen returns error", func(t *testing.T) {
dir := t.TempDir()
err := CreateProject(dir, "-project")
if err == nil {
t.Fatal("expected error for name starting with hyphen")
}
})
t.Run("name starting with underscore returns error", func(t *testing.T) {
dir := t.TempDir()
err := CreateProject(dir, "_project")
if err == nil {
t.Fatal("expected error for name starting with underscore")
}
})
}

View File

@ -105,6 +105,7 @@ a:hover {
.btn:hover {
background: var(--accent-hover);
color: #fff;
}
.btn-secondary {
@ -114,6 +115,7 @@ a:hover {
.btn-secondary:hover {
background: var(--border);
color: #fff;
}
.btn-full {
@ -620,6 +622,49 @@ a:hover {
background: var(--text-muted);
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-box {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 420px;
box-shadow: 0 8px 32px var(--shadow);
}
.modal-box h2 {
color: var(--accent);
margin-bottom: 1.2rem;
font-size: 1.2rem;
}
.modal-hint {
color: var(--text-muted);
font-size: 0.8rem;
margin-top: 0.3rem;
margin-bottom: 1rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
/* Hidden */
.hidden {
display: none !important;

View File

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="sr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Web Chat — Promena lozinke</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="login-container">
<div class="login-box">
<h1>Promena lozinke</h1>
{{if .Error}}
<div class="error-msg">{{.Error}}</div>
{{end}}
{{if .Success}}
<div class="success-msg">{{.Success}}</div>
{{end}}
<form method="POST" action="/change-password">
<div class="form-group">
<label for="current_password">Trenutna lozinka</label>
<input type="password" id="current_password" name="current_password" autocomplete="current-password" required autofocus>
</div>
<div class="form-group">
<label for="new_password">Nova lozinka</label>
<input type="password" id="new_password" name="new_password" autocomplete="new-password" required minlength="6">
</div>
<div class="form-group">
<label for="confirm_password">Potvrdi novu lozinku</label>
<input type="password" id="confirm_password" name="confirm_password" autocomplete="new-password" required minlength="6">
</div>
<button type="submit" class="btn btn-full">Promeni lozinku</button>
</form>
<div class="form-footer">
<a href="/projects">&larr; Nazad na projekte</a>
</div>
</div>
</div>
</body>
</html>

View File

@ -10,8 +10,33 @@
<div class="projects-container">
<div class="projects-header">
<h1>Projekti</h1>
<div>
<button class="btn btn-secondary" onclick="document.getElementById('createModal').classList.remove('hidden')">Novi projekat</button>
<a href="/change-password" class="btn btn-secondary">Promeni lozinku</a>
<a href="/logout" class="btn">Odjavi se</a>
</div>
</div>
{{if .Error}}
<div class="error-msg">{{.Error}}</div>
{{end}}
<div id="createModal" class="modal-overlay hidden" onclick="if(event.target===this)this.classList.add('hidden')">
<div class="modal-box">
<h2>Novi projekat</h2>
<form method="POST" action="/projects/create">
<div class="form-group">
<label for="projectName">Ime projekta</label>
<input type="text" id="projectName" name="name" placeholder="moj-projekat" required autofocus>
</div>
<p class="modal-hint">Dozvoljeni karakteri: slova, brojevi, - i _</p>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('createModal').classList.add('hidden')">Otkaži</button>
<button type="submit" class="btn">Kreiraj</button>
</div>
</form>
</div>
</div>
{{if .Projects}}
<div class="projects-grid">
{{range .Projects}}