`tasks.created_by` was ON DELETE CASCADE, so retiring a colleague removed every task they had ever written — including in projects somebody else owns. The interface said so in plain words before you pressed the button, which is a warning rather than a fix. It was the last remaining way to lose other people's work by accident. SET NULL instead: the task stays and loses its author, the same shape `assignee_id` and `completed_by` already have. SQLite cannot alter a foreign key action, so the table is rebuilt — and with foreign keys on, the drop in the middle of a rebuild is a cascading delete that takes every row referencing `tasks` with it. `PRAGMA foreign_keys` is a no-op inside a transaction, so migrations now run on a connection of their own, and a migration can opt out of enforcement with `-- verdande:rebuild-tables`. `PRAGMA foreign_key_check` runs before the commit, so a rebuild that leaves a dangling reference fails the migration rather than writing it. The test fills a database at 0007, upgrades it, and counts. Without the marker it loses a task, a label link and a comment — which is exactly what it is for. The admin list now says two numbers instead of one: what disappears, and what stays behind unattributed. They are different sentences and the second is a reassurance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
383 lines
14 KiB
Go
383 lines
14 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"testing"
|
|
)
|
|
|
|
// The instance's membership is an administrator's list, and creating an account
|
|
// means issuing an invite — there is no other door, and no password chosen by
|
|
// somebody other than its owner.
|
|
func TestAnAdminCanInviteSomebodyToTheInstance(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
resp, body := ts.do(t, "POST", "/api/v1/users", map[string]any{"email": "ny@example.dk"})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("invite: %d %v", resp.StatusCode, body)
|
|
}
|
|
link, _ := body["link"].(string)
|
|
if link == "" {
|
|
t.Fatal("no link came back — with no mail server it is the only way the invite reaches anybody")
|
|
}
|
|
if body["emailed"] != false {
|
|
t.Errorf("emailed = %v, want false with no mail server configured", body["emailed"])
|
|
}
|
|
|
|
// It shows as pending, with no project attached: this is an invite to the
|
|
// instance, not to somewhere in it.
|
|
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
|
|
invites, _ := listed["invites"].([]any)
|
|
if len(invites) != 1 {
|
|
t.Fatalf("want one pending invite, got %v", invites)
|
|
}
|
|
invite := invites[0].(map[string]any)
|
|
if invite["email"] != "ny@example.dk" {
|
|
t.Errorf("email = %v", invite["email"])
|
|
}
|
|
if name, ok := invite["project_name"]; ok && name != "" {
|
|
t.Errorf("project_name = %v, want none for an instance invite", name)
|
|
}
|
|
|
|
// And the token in the link creates the account, through the same signup the
|
|
// project invites use.
|
|
token := link[len(link)-len(link)+indexAfter(link, "token="):]
|
|
fresh := newSignedOut(t, ts)
|
|
resp, _ = fresh.do(t, "POST", "/api/v1/auth/signup", map[string]any{
|
|
"token": token, "name": "Ny Bruger", "password": "et langt kodeord",
|
|
})
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("signup with the instance invite: status %d", resp.StatusCode)
|
|
}
|
|
|
|
// They are on the instance, and they are not an administrator.
|
|
_, after := ts.do(t, "GET", "/api/v1/users", nil)
|
|
users, _ := after["users"].([]any)
|
|
if len(users) != 2 {
|
|
t.Fatalf("want two accounts, got %v", users)
|
|
}
|
|
for _, raw := range users {
|
|
u := raw.(map[string]any)
|
|
if u["email"] == "ny@example.dk" && u["is_admin"] != false {
|
|
t.Errorf("a newly invited account is an administrator: %v", u)
|
|
}
|
|
}
|
|
// The invite is used up rather than left live.
|
|
if pending, _ := after["invites"].([]any); len(pending) != 0 {
|
|
t.Errorf("the invite is still pending after being accepted: %v", pending)
|
|
}
|
|
}
|
|
|
|
// The two refusals that make this page safe to have at all.
|
|
func TestTheLastAdministratorCannotBeRemoved(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Demoting yourself as the only administrator locks everybody out: there is no
|
|
// console, and setup refuses to run once an account exists.
|
|
resp, body := ts.do(t, "PATCH", "/api/v1/users/"+me.ID, map[string]any{"is_admin": false})
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Fatalf("demote the last admin: status %d, want 409", resp.StatusCode)
|
|
}
|
|
if body["code"] != "last_admin" {
|
|
t.Errorf("code = %v, want last_admin — the interface has to explain this one", body["code"])
|
|
}
|
|
|
|
// And deleting the account you are signed in as is refused before that check
|
|
// is even reached.
|
|
resp, _ = ts.do(t, "DELETE", "/api/v1/users/"+me.ID, nil)
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Errorf("delete your own account: status %d, want 409", resp.StatusCode)
|
|
}
|
|
|
|
// Still an administrator, and still there.
|
|
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
|
|
first := listed["users"].([]any)[0].(map[string]any)
|
|
if first["is_admin"] != true || first["self"] != true {
|
|
t.Errorf("the only administrator is no longer one: %v", first)
|
|
}
|
|
}
|
|
|
|
// With a second administrator the refusal lifts — it is about the last one, not
|
|
// about administrators.
|
|
func TestASecondAdministratorCanBeDemoted(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
ts.newUser(t, "anden@example.dk", "Anden")
|
|
|
|
other, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if resp, _ := ts.do(t, "PATCH", "/api/v1/users/"+other.ID,
|
|
map[string]any{"is_admin": true}); resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("promote: status %d", resp.StatusCode)
|
|
}
|
|
if resp, _ := ts.do(t, "PATCH", "/api/v1/users/"+other.ID,
|
|
map[string]any{"is_admin": false}); resp.StatusCode != http.StatusNoContent {
|
|
t.Errorf("demote a second administrator: status %d, want 204", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// Deleting an account takes its own projects and leaves everything else behind.
|
|
// The list says both numbers before anybody presses the button.
|
|
func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
|
|
|
otherUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// One project of their own, with a task in it.
|
|
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Deres"})
|
|
other.do(t, "POST", "/api/v1/tasks", map[string]any{
|
|
"content": "deres egen", "project_id": theirs["id"].(string),
|
|
})
|
|
|
|
// And a task they wrote in a project somebody else owns. This one survives —
|
|
// `created_by` is ON DELETE SET NULL — and it is counted separately, because
|
|
// "this much disappears" and "this much stays unattributed" are two different
|
|
// things to tell somebody who is about to press delete.
|
|
_, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
|
|
sharedID := mine["id"].(string)
|
|
ts.do(t, "POST", "/api/v1/projects/"+sharedID+"/invites", map[string]any{
|
|
"email": "anden@example.dk", "role": "editor",
|
|
})
|
|
other.do(t, "POST", "/api/v1/tasks", map[string]any{
|
|
"content": "skrevet i et delt projekt", "project_id": sharedID,
|
|
})
|
|
|
|
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
|
|
var found map[string]any
|
|
for _, raw := range listed["users"].([]any) {
|
|
if u := raw.(map[string]any); u["id"] == otherUser.ID {
|
|
found = u
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatal("the second account is not in the list")
|
|
}
|
|
if found["project_count"] != float64(1) {
|
|
t.Errorf("project_count = %v, want 1 — the Inbox must not be counted", found["project_count"])
|
|
}
|
|
if found["task_count"] != float64(1) {
|
|
t.Errorf("task_count = %v, want 1 — only the task in their own project goes",
|
|
found["task_count"])
|
|
}
|
|
if found["authored_elsewhere"] != float64(1) {
|
|
t.Errorf("authored_elsewhere = %v, want 1 — the task in the shared project stays",
|
|
found["authored_elsewhere"])
|
|
}
|
|
|
|
// And the delete leaves it standing, without an author.
|
|
if resp, _ := ts.do(t, "DELETE", "/api/v1/users/"+otherUser.ID, nil); resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("delete: status %d", resp.StatusCode)
|
|
}
|
|
_, tasks := ts.do(t, "GET", "/api/v1/tasks?project_id="+sharedID, nil)
|
|
got, _ := tasks["tasks"].([]any)
|
|
if len(got) != 1 {
|
|
t.Fatalf("a task written by the deleted account should survive in a shared project, got %v", got)
|
|
}
|
|
surviving := got[0].(map[string]any)
|
|
if surviving["content"] != "skrevet i et delt projekt" {
|
|
t.Errorf("the surviving task is not the one that was written: %v", surviving["content"])
|
|
}
|
|
if surviving["created_by"] != "" {
|
|
t.Errorf("created_by = %q, want empty — the author is gone, the work is not",
|
|
surviving["created_by"])
|
|
}
|
|
}
|
|
|
|
// The tasks table was rebuilt to change created_by's delete action, and a rebuild
|
|
// is where an index quietly stops matching its table: tasks_fts is external-content
|
|
// and keyed on tasks.rowid, which a rebuilt table hands out afresh. Search is the
|
|
// only place that would notice.
|
|
func TestSearchStillFindsTasksAfterTheTableRebuild(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Have"})
|
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
|
"content": "beskær det grønne hegn", "project_id": project["id"].(string),
|
|
})
|
|
|
|
for _, q := range []string{"grønne", "gronne", "hegn"} {
|
|
_, found := ts.do(t, "GET", "/api/v1/search?q="+url.QueryEscape(q), nil)
|
|
if got, _ := found["tasks"].([]any); len(got) != 1 {
|
|
t.Errorf("search for %q found %d tasks, want 1", q, len(got))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Everything here is administrators only, and not reachable with an API token
|
|
// even by an administrator: a leaked token must not be able to mint an account.
|
|
func TestTheUserPageIsAdminsOnlyAndSessionsOnly(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
|
|
|
for _, c := range []struct {
|
|
what, method, path string
|
|
body any
|
|
}{
|
|
{"list", "GET", "/api/v1/users", nil},
|
|
{"invite", "POST", "/api/v1/users", map[string]any{"email": "x@y.dk"}},
|
|
{"promote", "PATCH", "/api/v1/users/whoever", map[string]any{"is_admin": true}},
|
|
{"delete", "DELETE", "/api/v1/users/whoever", nil},
|
|
{"revoke", "DELETE", "/api/v1/invites/whichever", nil},
|
|
} {
|
|
if resp, _ := other.do(t, c.method, c.path, c.body); resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("%s as an ordinary user: status %d, want 403", c.what, resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
token := ts.apiToken(t, admin.ID)
|
|
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/users", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := (&http.Client{}).Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("an administrator's API token reached the user list: status %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// A pending invite can be withdrawn, which is the only way to stop a link that
|
|
// went to the wrong address: the token is stored as a hash and cannot be looked up.
|
|
func TestAPendingInviteCanBeWithdrawn(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
_, created := ts.do(t, "POST", "/api/v1/users", map[string]any{"email": "forkert@example.dk"})
|
|
link := created["link"].(string)
|
|
|
|
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
|
|
inviteID := listed["invites"].([]any)[0].(map[string]any)["id"].(string)
|
|
|
|
if resp, _ := ts.do(t, "DELETE", "/api/v1/invites/"+inviteID, nil); resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("withdraw: status %d", resp.StatusCode)
|
|
}
|
|
|
|
// The link no longer works.
|
|
token := link[indexAfter(link, "token="):]
|
|
fresh := newSignedOut(t, ts)
|
|
resp, _ := fresh.do(t, "POST", "/api/v1/auth/signup", map[string]any{
|
|
"token": token, "name": "Forkert", "password": "et langt kodeord",
|
|
})
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("a withdrawn invite still signs somebody up: status %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// An address that already has an account is a conflict rather than a second
|
|
// invite: the signup would fail at the end, after somebody had chosen a password.
|
|
func TestInvitingAnAddressThatAlreadyHasAnAccount(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
resp, body := ts.do(t, "POST", "/api/v1/users",
|
|
map[string]any{"email": "kristian@example.dk"})
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Fatalf("status %d, want 409 — body %v", resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
// newSignedOut is a client against the same server with an empty cookie jar.
|
|
func newSignedOut(t *testing.T, ts *testServer) *testServer {
|
|
t.Helper()
|
|
return &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
|
}
|
|
|
|
// indexAfter returns the offset just past `needle` in `s`.
|
|
func indexAfter(s, needle string) int {
|
|
for i := 0; i+len(needle) <= len(s); i++ {
|
|
if s[i:i+len(needle)] == needle {
|
|
return i + len(needle)
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// A 500 has to leave something behind that a restart cannot take.
|
|
//
|
|
// The panel's watcher already reports that one happened. What it cannot report is
|
|
// what it was: that lives in the container's log, and a Rune replaces the
|
|
// container on every restart, so the explanation is usually gone before anybody
|
|
// looks.
|
|
func TestAServerErrorIsKeptWhereARestartCannotTakeIt(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
// Nothing has gone wrong yet.
|
|
_, empty := ts.do(t, "GET", "/api/v1/errors", nil)
|
|
if rows, _ := empty["errors"].([]any); len(rows) != 0 {
|
|
t.Fatalf("a fresh instance has errors already: %v", rows)
|
|
}
|
|
|
|
// Break something the handler cannot survive: the table it reads is gone, so
|
|
// the query fails and the handler answers 500. Dropping it is a blunt way to
|
|
// provoke that, and a faithful one — it is the same code path a disk error or
|
|
// a corrupt page would take.
|
|
if _, err := ts.db.ExecContext(t.Context(), `DROP TABLE project_groups`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp, _ := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
|
if resp.StatusCode != http.StatusInternalServerError {
|
|
t.Fatalf("status %d, want 500 — the fault was not provoked", resp.StatusCode)
|
|
}
|
|
|
|
_, body := ts.do(t, "GET", "/api/v1/errors", nil)
|
|
rows, _ := body["errors"].([]any)
|
|
if len(rows) != 1 {
|
|
t.Fatalf("want one recorded error, got %v", rows)
|
|
}
|
|
row := rows[0].(map[string]any)
|
|
if row["what"] != "list project groups" {
|
|
t.Errorf("what = %v — the operation is the part a status code cannot give", row["what"])
|
|
}
|
|
if row["path"] != "/api/v1/project-groups" || row["method"] != "GET" {
|
|
t.Errorf("path/method = %v %v", row["method"], row["path"])
|
|
}
|
|
if row["status"] != float64(500) {
|
|
t.Errorf("status = %v", row["status"])
|
|
}
|
|
if row["message"] == "" {
|
|
t.Error("no message: the row says something broke and not what")
|
|
}
|
|
if row["user_name"] != "Kristian" {
|
|
t.Errorf("user_name = %v — a fault only one account hits is a different problem",
|
|
row["user_name"])
|
|
}
|
|
if row["request_id"] == "" {
|
|
t.Error("no request id, so the row cannot be tied back to the log line")
|
|
}
|
|
}
|
|
|
|
// The list is an administrator's: it carries paths, accounts and the server's own
|
|
// error strings.
|
|
func TestTheErrorLogIsAdminsOnly(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
|
|
|
if resp, _ := other.do(t, "GET", "/api/v1/errors", nil); resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("status %d, want 403", resp.StatusCode)
|
|
}
|
|
}
|