feat: add account and card lifecycle controls
Test and publish / verify (push) Successful in 2m31s

This commit is contained in:
2026-07-29 12:50:13 +03:30
parent eba6b3565c
commit 71d253fb03
6 changed files with 502 additions and 33 deletions
+169 -2
View File
@@ -1,6 +1,7 @@
package app
import (
"database/sql"
"net/http"
"net/http/httptest"
"net/url"
@@ -12,6 +13,62 @@ import (
"time"
)
func TestExistingDatabaseAddsNewColumns(t *testing.T) {
path := filepath.Join(t.TempDir(), "legacy.db")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT,
display_name TEXT NOT NULL,
email TEXT UNIQUE COLLATE NOCASE,
role TEXT NOT NULL DEFAULT 'member',
avatar_url TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users(username,display_name,role) VALUES('legacy-admin','Legacy Admin','admin');
CREATE TABLE board_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'backlog',
assignee_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
due_date TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO board_tasks(title,creator_id) VALUES('Legacy card',1)`)
if err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
store, err := OpenStore(path)
if err != nil {
t.Fatalf("open legacy database: %v", err)
}
defer store.Close()
var active bool
if err := store.db.QueryRow(`SELECT active FROM users WHERE username='legacy-admin'`).Scan(&active); err != nil {
t.Fatalf("read migrated account state: %v", err)
}
if !active {
t.Fatal("legacy account was not active after migration")
}
var archivedAt sql.NullString
if err := store.db.QueryRow(`SELECT archived_at FROM board_tasks WHERE title='Legacy card'`).Scan(&archivedAt); err != nil {
t.Fatalf("read migrated card archive state: %v", err)
}
if archivedAt.Valid {
t.Fatalf("legacy card was unexpectedly archived: %q", archivedAt.String)
}
}
func TestLoginAttendanceAndReportFlow(t *testing.T) {
s, err := New(Config{
Addr: ":0",
@@ -62,6 +119,67 @@ func TestLoginAttendanceAndReportFlow(t *testing.T) {
if _, err := s.store.Authenticate("sara", "temporary-password"); err != nil {
t.Fatalf("created teammate could not authenticate: %v", err)
}
users, err := s.store.Users()
if err != nil {
t.Fatal(err)
}
var sara User
for _, user := range users {
if user.Username == "sara" {
sara = user
break
}
}
if sara.ID == 0 || !sara.Active {
t.Fatalf("created teammate missing or inactive: %#v", sara)
}
saraToken, _, err := s.store.CreateSession(sara.ID)
if err != nil {
t.Fatal(err)
}
lockUser := formRequest(t, s.http.Handler, "/admin/users/"+strconv.FormatInt(sara.ID, 10)+"/status", url.Values{
"csrf": {csrf},
"action": {"lock"},
}, session)
if lockUser.Code != http.StatusSeeOther || lockUser.Header().Get("Location") != "/admin/users?flash=Account+locked" {
t.Fatalf("lock user: got %d location %q", lockUser.Code, lockUser.Header().Get("Location"))
}
if _, err := s.store.Authenticate("sara", "temporary-password"); err == nil || !strings.Contains(err.Error(), "locked") {
t.Fatalf("locked teammate authentication result: %v", err)
}
if _, _, err := s.store.Session(saraToken); err == nil {
t.Fatal("locking a teammate did not revoke the active session")
}
if activeUsers, err := s.store.ActiveUsers(); err != nil || len(activeUsers) != 1 {
t.Fatalf("locked teammate was not hidden from active users: %#v, %v", activeUsers, err)
}
if roster, err := s.store.DayRoster(time.Now().Format("2006-01-02")); err != nil || len(roster) != 1 {
t.Fatalf("locked teammate was not hidden from the team roster: %#v, %v", roster, err)
}
usersRequest := httptest.NewRequest(http.MethodGet, "/admin/users", nil)
usersRequest.AddCookie(session)
usersResponse := httptest.NewRecorder()
s.http.Handler.ServeHTTP(usersResponse, usersRequest)
if usersResponse.Code != http.StatusOK || !strings.Contains(usersResponse.Body.String(), "Locked") || !strings.Contains(usersResponse.Body.String(), "Unlock") {
t.Fatalf("locked account state missing from admin directory: %d %s", usersResponse.Code, usersResponse.Body.String())
}
lockSelf := formRequest(t, s.http.Handler, "/admin/users/1/status", url.Values{
"csrf": {csrf},
"action": {"lock"},
}, session)
if lockSelf.Code != http.StatusSeeOther || !strings.Contains(lockSelf.Header().Get("Location"), "error=") {
t.Fatalf("administrator could lock their own account: %d %q", lockSelf.Code, lockSelf.Header().Get("Location"))
}
unlockUser := formRequest(t, s.http.Handler, "/admin/users/"+strconv.FormatInt(sara.ID, 10)+"/status", url.Values{
"csrf": {csrf},
"action": {"unlock"},
}, session)
if unlockUser.Code != http.StatusSeeOther || unlockUser.Header().Get("Location") != "/admin/users?flash=Account+unlocked" {
t.Fatalf("unlock user: got %d location %q", unlockUser.Code, unlockUser.Header().Get("Location"))
}
if _, err := s.store.Authenticate("sara", "temporary-password"); err != nil {
t.Fatalf("unlocked teammate could not authenticate: %v", err)
}
checkIn := formRequest(t, s.http.Handler, "/attendance/check-in", url.Values{
"csrf": {csrf},
@@ -564,6 +682,15 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) {
t.Fatalf("board page does not include %q", expected)
}
}
adminBoardRequest := httptest.NewRequest(http.MethodGet, "/board", nil)
adminBoardRequest.AddCookie(adminCookie)
adminBoardResponse := httptest.NewRecorder()
s.http.Handler.ServeHTTP(adminBoardResponse, adminBoardRequest)
for _, expected := range []string{"Archive", "Delete", "Delete permanently"} {
if !strings.Contains(adminBoardResponse.Body.String(), expected) {
t.Fatalf("card owner controls do not include %q", expected)
}
}
tagPath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/tags"
setTags := formRequest(t, s.http.Handler, tagPath, url.Values{
@@ -609,13 +736,53 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) {
t.Fatal("non-creator removed a board task")
}
archivePath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/archive"
deniedArchive := formRequest(t, s.http.Handler, archivePath, url.Values{"csrf": {memberCSRF}}, memberCookie)
if deniedArchive.Code != http.StatusSeeOther || !strings.Contains(deniedArchive.Header().Get("Location"), "error=") {
t.Fatalf("non-creator archive was not rejected: %d %q", deniedArchive.Code, deniedArchive.Header().Get("Location"))
}
adminArchive := formRequest(t, s.http.Handler, archivePath, url.Values{"csrf": {adminCSRF}}, adminCookie)
if adminArchive.Code != http.StatusSeeOther || adminArchive.Header().Get("Location") != "/board?flash=Task+archived" {
t.Fatalf("admin archive: got %d location %q", adminArchive.Code, adminArchive.Header().Get("Location"))
}
if active, _ := s.store.BoardTasks(); len(active) != 0 {
t.Fatalf("archived card remained on active board: %#v", active)
}
archived, err := s.store.ArchivedBoardTasks()
if err != nil || len(archived) != 1 || archived[0].Title != "Ship the onboarding flow" {
t.Fatalf("archived cards: %#v, %v", archived, err)
}
archivedPageRequest := httptest.NewRequest(http.MethodGet, "/board", nil)
archivedPageRequest.AddCookie(adminCookie)
archivedPage := httptest.NewRecorder()
s.http.Handler.ServeHTTP(archivedPage, archivedPageRequest)
for _, expected := range []string{"Archived cards", "Ship the onboarding flow", "Restore", "Delete permanently"} {
if !strings.Contains(archivedPage.Body.String(), expected) {
t.Fatalf("archived card panel does not include %q", expected)
}
}
restorePath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/restore"
adminRestore := formRequest(t, s.http.Handler, restorePath, url.Values{"csrf": {adminCSRF}}, adminCookie)
if adminRestore.Code != http.StatusSeeOther || adminRestore.Header().Get("Location") != "/board?flash=Task+restored" {
t.Fatalf("admin restore: got %d location %q", adminRestore.Code, adminRestore.Header().Get("Location"))
}
if active, _ := s.store.BoardTasks(); len(active) != 1 {
t.Fatalf("restored card did not return to board: %#v", active)
}
adminArchive = formRequest(t, s.http.Handler, archivePath, url.Values{"csrf": {adminCSRF}}, adminCookie)
if adminArchive.Code != http.StatusSeeOther {
t.Fatalf("second admin archive status: %d", adminArchive.Code)
}
adminDelete := formRequest(t, s.http.Handler, deletePath, url.Values{"csrf": {adminCSRF}}, adminCookie)
if adminDelete.Code != http.StatusSeeOther {
t.Fatalf("admin delete status: %d", adminDelete.Code)
if adminDelete.Code != http.StatusSeeOther || adminDelete.Header().Get("Location") != "/board?flash=Task+permanently+deleted" {
t.Fatalf("admin delete: got %d location %q", adminDelete.Code, adminDelete.Header().Get("Location"))
}
if remaining, _ := s.store.BoardTasks(); len(remaining) != 0 {
t.Fatal("admin could not remove the board task")
}
if remaining, _ := s.store.ArchivedBoardTasks(); len(remaining) != 0 {
t.Fatal("permanently deleted card remained in archive")
}
}
func formRequest(t *testing.T, handler http.Handler, path string, values url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {