diff --git a/TESTING.md b/TESTING.md index 5667999..6d9717d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 diff --git a/change_password_test.go b/change_password_test.go new file mode 100644 index 0000000..dd75c81 --- /dev/null +++ b/change_password_test.go @@ -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") + } +} diff --git a/config_test.go b/config_test.go index 3e1e8d9..320707a 100644 --- a/config_test.go +++ b/config_test.go @@ -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) { diff --git a/main.go b/main.go index 8bc37e3..d2ce13d 100644 --- a/main.go +++ b/main.go @@ -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 == "" { diff --git a/projects.go b/projects.go index e5f5ccc..0e23331 100644 --- a/projects.go +++ b/projects.go @@ -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) +} diff --git a/projects_test.go b/projects_test.go index 893bcd4..10e0e52 100644 --- a/projects_test.go +++ b/projects_test.go @@ -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") + } + }) +} diff --git a/static/style.css b/static/style.css index 8428146..5a3db51 100644 --- a/static/style.css +++ b/static/style.css @@ -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; diff --git a/templates/change-password.html b/templates/change-password.html new file mode 100644 index 0000000..ea5e5c8 --- /dev/null +++ b/templates/change-password.html @@ -0,0 +1,40 @@ + + + + + + Claude Web Chat — Promena lozinke + + + +
+
+

Promena lozinke

+ {{if .Error}} +
{{.Error}}
+ {{end}} + {{if .Success}} +
{{.Success}}
+ {{end}} +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ + diff --git a/templates/projects.html b/templates/projects.html index 4a5b58d..8294f20 100644 --- a/templates/projects.html +++ b/templates/projects.html @@ -10,7 +10,32 @@

Projekti

- Odjavi se +
+ + Promeni lozinku + Odjavi se +
+
+ + {{if .Error}} +
{{.Error}}
+ {{end}} + + {{if .Projects}}