Release / Build and publish image (push) Failing after 10m0s
The README has promised "internal users are created by an admin" since the beginning and there was no such thing. There are exactly two ways an account has ever come into existence: the first admin at setup, and somebody accepting an invite to a project. So the only way to add a person was to share a project with them first. The store has been ready the whole time. CreateInvite already documents that "projectID may be empty, which invites somebody to the instance rather than to a particular project", and AcceptInvite already has the branch that grants no membership when there is none. requireAdmin has existed in the middleware and was used nowhere. This wires those three together and puts a page on them. An invite rather than an account with a password the administrator picks. A password typed here would be known to two people before it was ever used, and would then have to travel to the second one by some channel nobody has thought about. No name is asked for either — the signup form asks the person, and a name an administrator guesses is a name somebody has to correct. Two refusals keep the instance administrable: the last administrator cannot be demoted or deleted, and nobody can delete the account they are signed in as. Both are enforced on the server; the interface simply does not draw the buttons on your own row, because a button whose only purpose is to be refused wastes a click to teach a rule. The last-admin refusal gets its own error code, because "that already exists" is not what happened and this is the one refusal a person needs explained. Deleting an account is the only hard delete in this database, and building the confirmation is what turned up how wide it goes. `projects.owner_id` cascades, which is expected. But `tasks.created_by` cascades too — so removing a colleague deletes every task they ever wrote, including in projects somebody else owns. The count in the list says so, and the confirmation says it in words. Changing that to ON DELETE SET NULL, so the work stays and loses its author, means rebuilding the tasks table and its FTS triggers: worth doing, not worth doing quietly while adding a screen. It is written down in the store instead. Pending invites are listed and can be withdrawn. Only the token's hash is stored, so a link sent to the wrong address cannot be looked up and resent — withdrawing it is the only way to stop it before it expires. Six API tests for the guards and the counts, and two smoke tests: the whole loop from invite to a second browser creating the account and appearing in the list as somebody who is not an administrator, and the absence of buttons on your own row. Co-Authored-By: Claude Opus 5 <[email protected]>
280 lines
10 KiB
Go
280 lines
10 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"net/http"
|
|
"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": "[email protected]"})
|
|
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"] != "[email protected]" {
|
|
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"] == "[email protected]" && 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(), "[email protected]")
|
|
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, "[email protected]", "Anden")
|
|
|
|
other, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
|
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 more than its own projects, and the list says so
|
|
// before anybody presses the button.
|
|
func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
other := ts.newUser(t, "[email protected]", "Anden")
|
|
|
|
otherUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
|
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. tasks.created_by
|
|
// cascades, so this one goes too — and a count that missed it would understate
|
|
// the damage in exactly the case that matters.
|
|
_, 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": "[email protected]", "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(2) {
|
|
t.Errorf("task_count = %v, want 2 — the task in the shared project cascades too",
|
|
found["task_count"])
|
|
}
|
|
|
|
// And the delete really does take it.
|
|
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)
|
|
if got, _ := tasks["tasks"].([]any); len(got) != 0 {
|
|
t.Errorf("a task written by the deleted account survived in a shared project: %v", 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, "[email protected]", "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": "[email protected]"}},
|
|
{"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(), "[email protected]")
|
|
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": "[email protected]"})
|
|
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": "[email protected]"})
|
|
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
|
|
}
|