Files
verdande/internal/httpapi/users_test.go
T
Kristian WindandClaude Opus 5 ebdc8eacf2 feat(admin): deleting an account stops deleting other people's work
`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>
2026-08-18 09:03:59 +02:00

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)
}
}