Add support for shared todo items across multiple users. Patchnotes: Added SharedWith field; Implemented authorization for shared users; Updated BoltStore for shared todo retrieval; Updated client to prompt for shared users; Added integration tests. #3

Open
DariusKlein wants to merge 2 commits from feature/sharing-todos into main
5 changed files with 203 additions and 28 deletions
Showing only changes of commit f0126c656a - Show all commits

View File

@ -50,12 +50,24 @@ func CreateNewTodo() common.Todo {
for i, l := range labelSlice { for i, l := range labelSlice {
labelSlice[i] = strings.TrimSpace(l) labelSlice[i] = strings.TrimSpace(l)
} }
sharedInput := common.AskUserString("Shared with (comma separated usernames):\n")
sharedSlice := strings.Split(sharedInput, ",")
var sharedWith []string
for _, s := range sharedSlice {
s = strings.TrimSpace(s)
if s != "" {
sharedWith = append(sharedWith, s)
}
}
return common.Todo{ return common.Todo{
Name: common.AskUserString("Name:\n"), Name: common.AskUserString("Name:\n"),
Description: common.AskUserString("Description:\n"), Description: common.AskUserString("Description:\n"),
Status: common.AskUserString(fmt.Sprintf("Status (%s, %s, %s, %s, %s, %s):\n", Status: common.AskUserString(fmt.Sprintf("Status (%s, %s, %s, %s, %s, %s):\n",
common.NotStarted, common.Done, common.WIP, common.Pending, common.Blocked, common.Failed)), common.NotStarted, common.Done, common.WIP, common.Pending, common.Blocked, common.Failed)),
Labels: labelSlice, Labels: labelSlice,
SharedWith: sharedWith,
Owner: cfg.Server.Credentials.Username, Owner: cfg.Server.Credentials.Username,
LastModified: time.Now().UTC(), LastModified: time.Now().UTC(),
} }

View File

@ -7,6 +7,8 @@ import (
"path/filepath" "path/filepath"
"sync" "sync"
"slices"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
) )
@ -219,25 +221,38 @@ func (s *BoltStore) ExistsByKey(bucket, key string) (bool, error) {
} }
func (s *BoltStore) GetTodoMap(user string) map[string]Todo { func (s *BoltStore) GetTodoMap(user string) map[string]Todo {
storedTodoJsons := s.GetAllFromBucket(user)
serverTodos := make(map[string]Todo) serverTodos := make(map[string]Todo)
for key, val := range storedTodoJsons {
s.DB.View(func(tx *bolt.Tx) error {
return tx.ForEach(func(name []byte, b *bolt.Bucket) error {
bucketName := string(name)
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
var todo Todo var todo Todo
if json.Unmarshal([]byte(val), &todo) == nil { if json.Unmarshal(v, &todo) == nil {
serverTodos[key] = todo if bucketName == user || slices.Contains(todo.SharedWith, user) {
serverTodos[string(k)] = todo
} }
} }
}
return nil
})
})
return serverTodos return serverTodos
} }
func (s *BoltStore) GetTodoList(user string, includeDeleted bool) []Todo { func (s *BoltStore) GetTodoList(user string, includeDeleted bool) []Todo {
storedTodoJsons := s.GetAllFromBucket(user)
var storedTodos []Todo var storedTodos []Todo
for _, val := range storedTodoJsons {
s.DB.View(func(tx *bolt.Tx) error {
return tx.ForEach(func(name []byte, b *bolt.Bucket) error {
bucketName := string(name)
c := b.Cursor()
for _, v := c.First(); v != nil; _, v = c.Next() {
var todo Todo var todo Todo
if json.Unmarshal([]byte(val), &todo) == nil { if json.Unmarshal(v, &todo) == nil {
if bucketName == user || slices.Contains(todo.SharedWith, user) {
var include = false var include = false
if includeDeleted { if includeDeleted {
include = true include = true
@ -249,5 +264,10 @@ func (s *BoltStore) GetTodoList(user string, includeDeleted bool) []Todo {
} }
} }
} }
}
return nil
})
})
return storedTodos return storedTodos
} }

View File

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"hash/fnv" "hash/fnv"
"slices"
"strings" "strings"
"time" "time"
@ -12,7 +13,10 @@ import (
) )
func (todo *Todo) Store(store *BoltStore, user string) error { func (todo *Todo) Store(store *BoltStore, user string) error {
if todo.Owner != user { isOwner := todo.Owner == user
isShared := slices.Contains(todo.SharedWith, user)
if !isOwner && !isShared {
return fmt.Errorf("unauthorized user") return fmt.Errorf("unauthorized user")
} }
if todo.Id == "" { if todo.Id == "" {
@ -23,7 +27,7 @@ func (todo *Todo) Store(store *BoltStore, user string) error {
if err != nil { if err != nil {
return err return err
} }
return store.SaveValueToBucket(user, todo.Id, string(todoJson)) return store.SaveValueToBucket(todo.Owner, todo.Id, string(todoJson))
} }
func (todoRequest StoreTodoRequest) Store(store *BoltStore, user string) error { func (todoRequest StoreTodoRequest) Store(store *BoltStore, user string) error {
@ -33,6 +37,7 @@ func (todoRequest StoreTodoRequest) Store(store *BoltStore, user string) error {
Description: todoRequest.Description, Description: todoRequest.Description,
Status: todoRequest.Status, Status: todoRequest.Status,
Owner: user, Owner: user,
SharedWith: todoRequest.SharedWith,
LastModified: time.Now().UTC(), LastModified: time.Now().UTC(),
} }
todoJson, err := json.Marshal(todo) todoJson, err := json.Marshal(todo)
@ -114,6 +119,7 @@ func (t Todo) IsEqual(other Todo) bool {
t.Description == other.Description && t.Description == other.Description &&
t.Status == other.Status && t.Status == other.Status &&
t.Owner == other.Owner && t.Owner == other.Owner &&
slices.Equal(t.SharedWith, other.SharedWith) &&
t.Deleted == other.Deleted t.Deleted == other.Deleted
} }
@ -122,5 +128,6 @@ func (t Todo) IsEqualIgnoringStatus(other Todo) bool {
t.Name == other.Name && t.Name == other.Name &&
t.Description == other.Description && t.Description == other.Description &&
t.Owner == other.Owner && t.Owner == other.Owner &&
slices.Equal(t.SharedWith, other.SharedWith) &&
t.Deleted == other.Deleted t.Deleted == other.Deleted
} }

View File

@ -13,6 +13,7 @@ type Todo struct {
Description string `json:"description"` Description string `json:"description"`
Status string `json:"status"` Status string `json:"status"`
Owner string `json:"owner"` Owner string `json:"owner"`
SharedWith []string `json:"shared_with,omitzero"`
Labels []string `json:"labels"` Labels []string `json:"labels"`
LastModified time.Time `json:"last_modified"` LastModified time.Time `json:"last_modified"`
Deleted bool `json:"deleted"` Deleted bool `json:"deleted"`
@ -22,6 +23,7 @@ type StoreTodoRequest struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Status string `json:"status"` Status string `json:"status"`
SharedWith []string `json:"shared_with,omitzero"`
} }
type TodoList struct { type TodoList struct {

134
tests/sharing_test.go Normal file
View File

@ -0,0 +1,134 @@
package tests
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"gitea.kleinsense.nl/DariusKlein/kleinTodo/common"
"gitea.kleinsense.nl/DariusKlein/kleinTodo/server/handler"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSharedTodoSync(t *testing.T) {
// 1. Setup Environment
tempDir, err := os.MkdirTemp("", "todo-test-*")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
os.Setenv("XDG_CONFIG_HOME", tempDir)
os.Setenv("JWT_SECRET", "test-secret-123")
// 2. Initialize Server
mux := http.NewServeMux()
mux.HandleFunc("POST /register", handler.RegisterHandler)
mux.HandleFunc("POST /login", handler.LoginHandler)
mux.HandleFunc("POST /sync", handler.SyncHandler)
mux.HandleFunc("POST /store", handler.StoreHandler)
ts := httptest.NewServer(mux)
defer ts.Close()
client := ts.Client()
// 3. Register two users
users := []struct {
username, password string
token string
}{
{"owner", "pass1", ""},
{"shared", "pass2", ""},
}
for i, u := range users {
regPayload, _ := json.Marshal(common.Credentials{Username: u.username, Password: u.password})
resp, err := client.Post(ts.URL+"/register", "application/json", bytes.NewBuffer(regPayload))
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp, err = client.Post(ts.URL+"/login", "application/json", bytes.NewBuffer(regPayload))
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
users[i].token = resp.Header.Get(common.AuthHeader)
}
// 4. Owner creates a shared todo
sharedTodo := common.Todo{
Id: uuid.New().String(),
Name: "Shared Task",
Description: "This is shared",
Status: common.NotStarted,
Owner: "owner",
SharedWith: []string{"shared"},
LastModified: time.Now().UTC(),
}
syncReq := common.TodoList{Todos: []common.Todo{sharedTodo}}
syncPayload, _ := json.Marshal(syncReq)
req, _ := http.NewRequest("POST", ts.URL+"/sync", bytes.NewBuffer(syncPayload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(common.AuthHeader, "Bearer "+users[0].token)
resp, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
// 5. Shared user pulls todos
emptySyncReq := common.TodoList{Todos: []common.Todo{}}
emptySyncPayload, _ := json.Marshal(emptySyncReq)
req, _ = http.NewRequest("POST", ts.URL+"/sync", bytes.NewBuffer(emptySyncPayload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(common.AuthHeader, "Bearer "+users[1].token)
resp, err = client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var syncResp common.SyncResponse
err = json.NewDecoder(resp.Body).Decode(&syncResp)
require.NoError(t, err)
resp.Body.Close()
// Shared user should see the todo
assert.Len(t, syncResp.SyncedTodos, 1)
assert.Equal(t, "Shared Task", syncResp.SyncedTodos[0].Name)
assert.Equal(t, "owner", syncResp.SyncedTodos[0].Owner)
assert.Contains(t, syncResp.SyncedTodos[0].SharedWith, "shared")
// 6. Shared user updates the todo status
updatedTodo := syncResp.SyncedTodos[0]
updatedTodo.Status = common.Done
updatedTodo.LastModified = time.Now().UTC()
updateSyncReq := common.TodoList{Todos: []common.Todo{updatedTodo}}
updateSyncPayload, _ := json.Marshal(updateSyncReq)
req, _ = http.NewRequest("POST", ts.URL+"/sync", bytes.NewBuffer(updateSyncPayload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(common.AuthHeader, "Bearer "+users[1].token)
resp, err = client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
// 7. Owner pulls todos and checks status
req, _ = http.NewRequest("POST", ts.URL+"/sync", bytes.NewBuffer(emptySyncPayload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(common.AuthHeader, "Bearer "+users[0].token)
resp, err = client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
err = json.NewDecoder(resp.Body).Decode(&syncResp)
require.NoError(t, err)
resp.Body.Close()
assert.Len(t, syncResp.SyncedTodos, 1)
assert.Equal(t, common.Done, syncResp.SyncedTodos[0].Status, "Owner should see status update from shared user")
}