An invite sent with the wrong role could not be corrected. The only way back was to remove the person and invite them again — and removing a member unassigns every task they were responsible for, so fixing a dropdown cost somebody their work. This is the other side of "an external user cannot create tasks": the rule was right, the role was wrong, and there was no way to say so. PATCH against a membership that already exists, not the upsert AddMember does. Somebody who is not a member is not silently made one: that is an invite, it belongs to the invite flow, and doing it here would let a typo in a user id add a stranger to a project without anybody being asked. The owner is refused, with the real reason rather than the true but useless one. Ownership lives on the project rather than in project_members, so the plain answer would have been "not a member" — which is correct and tells nobody anything. Ownership is transferred, not granted, and two owners is a state with no way to settle a disagreement about who may remove whom. In the list, the owner's standing is text and everybody else's is a dropdown. A disabled control would be a thing that exists to say no. And the roles are finally in Danish. The API's words are `owner`, `editor` and `viewer`, and they were being printed straight into a Danish page; the list now uses the same three phrases the invite form beside it already did. The smoke test drives both browsers, because the interesting part is that the other one's permissions really change: a viewer is told the project is read-only and has no quick-add box, the owner promotes them from the list, and after a reload the box is there and the notice is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
286 lines
10 KiB
Go
286 lines
10 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
// Restoring has been possible since the trash existed. Finding what to restore
|
|
// has not: the endpoint takes an id, and the id is exactly what you no longer
|
|
// have once the project has gone from the interface.
|
|
func TestTheTrashCanBeListedAndRestored(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
_, created := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ferie"})
|
|
id := created["id"].(string)
|
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "book fly", "project_id": id})
|
|
|
|
if resp, _ := ts.do(t, "DELETE", "/api/v1/projects/"+id, nil); resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("delete: status %d", resp.StatusCode)
|
|
}
|
|
|
|
// Gone from the ordinary listing.
|
|
_, live := ts.do(t, "GET", "/api/v1/projects", nil)
|
|
for _, raw := range live["projects"].([]any) {
|
|
if raw.(map[string]any)["id"] == id {
|
|
t.Fatal("a deleted project is still in the project list")
|
|
}
|
|
}
|
|
|
|
// And findable in the trash, with enough to decide whether to bring it back.
|
|
_, trash := ts.do(t, "GET", "/api/v1/trash/projects", nil)
|
|
list, _ := trash["projects"].([]any)
|
|
if len(list) != 1 {
|
|
t.Fatalf("want one project in the trash, got %v", list)
|
|
}
|
|
entry := list[0].(map[string]any)
|
|
if entry["name"] != "Ferie" {
|
|
t.Errorf("name = %v", entry["name"])
|
|
}
|
|
if entry["task_count"] != float64(1) {
|
|
t.Errorf("task_count = %v, want 1 — the count is what says whether it matters",
|
|
entry["task_count"])
|
|
}
|
|
if entry["purge_after"] == nil || entry["deleted_at"] == nil {
|
|
t.Errorf("no deadline was reported: %v", entry)
|
|
}
|
|
|
|
if resp, _ := ts.do(t, "POST", "/api/v1/trash/projects/"+id+"/restore", nil); resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("restore: status %d", resp.StatusCode)
|
|
}
|
|
|
|
// Back, with its task.
|
|
_, tasks := ts.do(t, "GET", "/api/v1/tasks?project_id="+id, nil)
|
|
if got, _ := tasks["tasks"].([]any); len(got) != 1 {
|
|
t.Errorf("the task did not come back with the project: %v", got)
|
|
}
|
|
_, trash = ts.do(t, "GET", "/api/v1/trash/projects", nil)
|
|
if list, _ := trash["projects"].([]any); len(list) != 0 {
|
|
t.Errorf("still in the trash after restoring: %v", list)
|
|
}
|
|
}
|
|
|
|
// Somebody else's deleted project is not in your trash, and you cannot restore it.
|
|
func TestTheTrashIsPerOwner(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
_, created := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
|
|
id := created["id"].(string)
|
|
ts.do(t, "DELETE", "/api/v1/projects/"+id, nil)
|
|
|
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
|
|
|
_, trash := other.do(t, "GET", "/api/v1/trash/projects", nil)
|
|
if list, _ := trash["projects"].([]any); len(list) != 0 {
|
|
t.Errorf("somebody else's deleted project is visible: %v", list)
|
|
}
|
|
resp, _ := other.do(t, "POST", "/api/v1/trash/projects/"+id+"/restore", nil)
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Errorf("status %d, want 404", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestReorderProjectsWritesTheGivenOrder(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
var ids []string
|
|
for _, name := range []string{"Alfa", "Bravo", "Charlie"} {
|
|
_, body := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": name})
|
|
ids = append(ids, body["id"].(string))
|
|
}
|
|
|
|
// Back to front.
|
|
reversed := []string{ids[2], ids[1], ids[0]}
|
|
resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{"ids": reversed})
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("reorder: status %d", resp.StatusCode)
|
|
}
|
|
|
|
_, list := ts.do(t, "GET", "/api/v1/projects", nil)
|
|
projects, _ := list["projects"].([]any)
|
|
|
|
var got []string
|
|
for _, raw := range projects {
|
|
p := raw.(map[string]any)
|
|
// The Inbox sorts ahead of everything by its own rule; it is not part of
|
|
// what was reordered.
|
|
if p["is_inbox"] == true {
|
|
continue
|
|
}
|
|
got = append(got, p["id"].(string))
|
|
}
|
|
if len(got) != 3 || got[0] != reversed[0] || got[1] != reversed[1] || got[2] != reversed[2] {
|
|
t.Errorf("order = %v, want %v", got, reversed)
|
|
}
|
|
}
|
|
|
|
// sort_order is a column on the project, not a per-viewer preference, so
|
|
// reordering must not rearrange a project somebody else owns.
|
|
func TestReorderProjectsLeavesSomebodyElsesAlone(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
|
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Deres"})
|
|
theirID := theirs["id"].(string)
|
|
|
|
_, before := other.do(t, "GET", "/api/v1/projects", nil)
|
|
var wanted float64
|
|
for _, raw := range before["projects"].([]any) {
|
|
if p := raw.(map[string]any); p["id"] == theirID {
|
|
wanted = p["sort_order"].(float64)
|
|
}
|
|
}
|
|
|
|
// Ask, as somebody else, to put their project first.
|
|
resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{
|
|
"ids": []string{theirID},
|
|
})
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("reorder: status %d", resp.StatusCode)
|
|
}
|
|
|
|
_, after := other.do(t, "GET", "/api/v1/projects", nil)
|
|
for _, raw := range after["projects"].([]any) {
|
|
if p := raw.(map[string]any); p["id"] == theirID {
|
|
if p["sort_order"] != wanted {
|
|
t.Errorf("sort_order changed from %v to %v — somebody else moved it",
|
|
wanted, p["sort_order"])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The member list errored on every project — the ORDER BY named an expression,
|
|
// which SQLite refuses across a UNION — so the share panel had never shown
|
|
// anybody, not even the owner.
|
|
func TestListMembersWorksAtAll(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
|
|
|
resp, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
|
}
|
|
members, _ := body["members"].([]any)
|
|
if len(members) != 1 {
|
|
t.Fatalf("a project with no invites lists %d members, want its owner", len(members))
|
|
}
|
|
if m := members[0].(map[string]any); m["role"] != "owner" {
|
|
t.Errorf("role = %v, want owner", m["role"])
|
|
}
|
|
}
|
|
|
|
// And the Inbox, which is the one every task drawer asks about.
|
|
func TestListMembersWorksForTheInbox(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
_, list := ts.do(t, "GET", "/api/v1/projects", nil)
|
|
var inboxID string
|
|
for _, raw := range list["projects"].([]any) {
|
|
if p := raw.(map[string]any); p["is_inbox"] == true {
|
|
inboxID = p["id"].(string)
|
|
}
|
|
}
|
|
|
|
resp, body := ts.do(t, "GET", "/api/v1/projects/"+inboxID+"/members", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
// A role sent by mistake used to be uncorrectable: the only way back was to remove
|
|
// the person and invite them again, which unassigns every task they were
|
|
// responsible for. Fixing a dropdown should not cost somebody their work.
|
|
func TestAMembersRoleCanBeChangedWithoutRemovingThem(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
member := ts.newUser(t, "anden@example.dk", "Anden")
|
|
|
|
memberUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
|
projectID := project["id"].(string)
|
|
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
|
"email": "anden@example.dk", "role": "viewer",
|
|
})
|
|
|
|
// A viewer cannot write. That is the rule, not the bug.
|
|
if resp, _ := member.do(t, "POST", "/api/v1/tasks",
|
|
map[string]any{"content": "prøv", "project_id": projectID}); resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("a viewer created a task: status %d", resp.StatusCode)
|
|
}
|
|
|
|
resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
|
|
map[string]any{"role": "editor"})
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("promote to editor: status %d", resp.StatusCode)
|
|
}
|
|
|
|
if resp, _ := member.do(t, "POST", "/api/v1/tasks",
|
|
map[string]any{"content": "nu kan jeg", "project_id": projectID}); resp.StatusCode != http.StatusCreated {
|
|
t.Errorf("an editor could not create a task: status %d", resp.StatusCode)
|
|
}
|
|
_, members := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
|
for _, raw := range members["members"].([]any) {
|
|
if m := raw.(map[string]any); m["user_id"] == memberUser.ID && m["role"] != "editor" {
|
|
t.Errorf("role = %v after the change, want editor", m["role"])
|
|
}
|
|
}
|
|
}
|
|
|
|
// The owner is not a member row, and ownership is not a role you can hand out.
|
|
func TestTheOwnersStandingCannotBeChanged(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
member := ts.newUser(t, "anden@example.dk", "Anden")
|
|
|
|
owner, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
memberUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
|
projectID := project["id"].(string)
|
|
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
|
"email": "anden@example.dk", "role": "editor",
|
|
})
|
|
|
|
// Demoting the owner would leave a project nobody can administer.
|
|
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+owner.ID,
|
|
map[string]any{"role": "viewer"}); resp.StatusCode != http.StatusConflict {
|
|
t.Errorf("changing the owner's role: status %d, want 409", resp.StatusCode)
|
|
}
|
|
// And no member may promote themselves to owner.
|
|
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
|
|
map[string]any{"role": "owner"}); resp.StatusCode != http.StatusUnprocessableEntity {
|
|
t.Errorf("granting ownership: status %d, want 422", resp.StatusCode)
|
|
}
|
|
// Nor may an editor change anybody's role: this is the owner's to do.
|
|
if resp, _ := member.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
|
|
map[string]any{"role": "viewer"}); resp.StatusCode != http.StatusNotFound {
|
|
t.Errorf("an editor changed a role: status %d, want 404", resp.StatusCode)
|
|
}
|
|
// Somebody with no membership at all is not quietly given one.
|
|
stranger := ts.newUser(t, "tredje@example.dk", "Tredje")
|
|
_ = stranger
|
|
strangerUser, _ := ts.db.UserByEmail(t.Context(), "tredje@example.dk")
|
|
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+strangerUser.ID,
|
|
map[string]any{"role": "editor"}); resp.StatusCode != http.StatusNotFound {
|
|
t.Errorf("a non-member was given a role: status %d, want 404", resp.StatusCode)
|
|
}
|
|
}
|