package api_test import ( "bytes" "encoding/json" "net/http" "net/http/cookiejar" "testing" ) // signup posts to the unauthenticated sign-up endpoint, the way the form does, // and returns the response and a client holding whatever cookie came back. func signup(t *testing.T, s *ts, body map[string]any) (*http.Response, *http.Client) { t.Helper() data, _ := json.Marshal(body) jar, _ := cookiejar.New(nil) client := &http.Client{Jar: jar} req, _ := http.NewRequest(http.MethodPost, s.URL+"/api/signup", bytes.NewReader(data)) req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { t.Fatalf("signup: %v", err) } return resp, client } // invite mints a link into the default team and returns its raw token. func invite(t *testing.T, s *ts, role string, maxUses int64) string { t.Helper() var out struct { URL string `json:"url"` } decode(t, s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/invites", map[string]any{"role": role, "max_uses": maxUses}), &out) if out.URL == "" { t.Fatal("no invite URL returned") } // ...?invite= i := len(out.URL) - 1 for ; i >= 0 && out.URL[i] != '='; i-- { } return out.URL[i+1:] } func setSignupMode(t *testing.T, s *ts, mode string) { t.Helper() resp := s.req(t, http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": mode}) defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent { t.Fatalf("set signup mode: %d", resp.StatusCode) } } // The default is the closed door. An install that gets a public hostname before // anybody has thought about sign-up should not be collecting accounts. func TestSignup_InviteOnlyByDefault(t *testing.T) { s := newTS(t) var info map[string]any decode(t, s.req(t, http.MethodGet, "/api/signup", nil), &info) if info["mode"] != "invite_only" { t.Errorf("default sign-up mode is %v, want invite_only", info["mode"]) } resp, _ := signup(t, s, map[string]any{ "username": "stranger", "email": "s@test.com", "password": "correct-horse-battery", }) resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Errorf("sign-up without an invite: expected 403, got %d", resp.StatusCode) } } // An invite carries the team and the role, so redeeming one lands somewhere // usable rather than in an account that sees an empty queue. func TestSignup_InviteCreatesAMemberOfThatTeam(t *testing.T) { s := newTS(t) token := invite(t, s, "member", 1) // The form checks the link before asking for a password. var info map[string]any decode(t, s.req(t, http.MethodGet, "/api/signup?invite="+token, nil), &info) if info["invite_valid"] != true { t.Fatalf("a fresh invite should be valid: %v", info) } if info["invite_team"] != "Default" { t.Errorf("the form should name the team: %v", info["invite_team"]) } resp, client := signup(t, s, map[string]any{ "username": "newcomer", "email": "n@test.com", "password": "correct-horse-battery", "invite": token, }) if resp.StatusCode != http.StatusCreated { t.Fatalf("redeeming an invite: %d", resp.StatusCode) } var me struct { User struct { ID int64 `json:"id"` IsAdmin bool `json:"is_admin"` } `json:"user"` } decode(t, resp, &me) if me.User.IsAdmin { t.Error("somebody who signs up must not be an administrator") } // Signed in already: the cookie came back with the response. got, err := client.Get(s.URL + "/api/teams") if err != nil { t.Fatal(err) } teams := list(t, got) if len(teams) != 1 || teams[0]["name"] != "Default" || teams[0]["role"] != "member" { t.Errorf("expected membership of Default as member, got %v", teams) } } // A single-use link is single-use, and the check is inside the transaction so // two people redeeming the last use at once cannot both get in. func TestSignup_InviteCannotBeUsedTwice(t *testing.T) { s := newTS(t) token := invite(t, s, "member", 1) first, _ := signup(t, s, map[string]any{ "username": "first", "email": "f@test.com", "password": "correct-horse-battery", "invite": token, }) first.Body.Close() if first.StatusCode != http.StatusCreated { t.Fatalf("first redemption: %d", first.StatusCode) } second, _ := signup(t, s, map[string]any{ "username": "second", "email": "s@test.com", "password": "correct-horse-battery", "invite": token, }) second.Body.Close() if second.StatusCode != http.StatusForbidden { t.Errorf("second redemption: expected 403, got %d", second.StatusCode) } // And the link reports itself unusable before anybody types a password. var info map[string]any decode(t, s.req(t, http.MethodGet, "/api/signup?invite="+token, nil), &info) if info["invite_valid"] != false { t.Error("a used-up invite should report itself invalid") } } // Revoking stops a link without waiting for it to expire. func TestSignup_RevokedInviteStopsWorking(t *testing.T) { s := newTS(t) token := invite(t, s, "member", 5) invites := list(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/invites", nil)) if len(invites) != 1 { t.Fatalf("expected one invite, got %d", len(invites)) } id := int64(invites[0]["id"].(float64)) resp := s.req(t, http.MethodDelete, "/api/teams/"+defaultTeam+"/invites/"+id64(id), nil) resp.Body.Close() if resp.StatusCode != http.StatusNoContent { t.Fatalf("revoke: %d", resp.StatusCode) } used, _ := signup(t, s, map[string]any{ "username": "late", "email": "l@test.com", "password": "correct-horse-battery", "invite": token, }) used.Body.Close() if used.StatusCode != http.StatusForbidden { t.Errorf("a revoked invite: expected 403, got %d", used.StatusCode) } } // Open sign-up makes a team, because an account in no team sees an empty queue // and can be paged by nobody. func TestSignup_OpenModeMakesATeam(t *testing.T) { s := newTS(t) setSignupMode(t, s, "open") missing, _ := signup(t, s, map[string]any{ "username": "solo", "email": "s@test.com", "password": "correct-horse-battery", }) missing.Body.Close() if missing.StatusCode != http.StatusBadRequest { t.Errorf("open sign-up with no team name: expected 400, got %d", missing.StatusCode) } resp, client := signup(t, s, map[string]any{ "username": "solo", "email": "s@test.com", "password": "correct-horse-battery", "team_name": "Solo", }) resp.Body.Close() if resp.StatusCode != http.StatusCreated { t.Fatalf("open sign-up: %d", resp.StatusCode) } got, err := client.Get(s.URL + "/api/teams") if err != nil { t.Fatal(err) } teams := list(t, got) if len(teams) != 1 || teams[0]["name"] != "Solo" || teams[0]["role"] != "owner" { t.Errorf("the creator should own their new team, got %v", teams) } } // Switching the mode is an administrator's decision, and it takes effect at // once rather than at the next restart. func TestSignup_ModeIsAnAdminSetting(t *testing.T) { s := newTS(t) _, call := member(t, s, "plain") resp := call(http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": "open"}) resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Errorf("a member changing the mode: expected 403, got %d", resp.StatusCode) } bad := s.req(t, http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": "everybody"}) bad.Body.Close() if bad.StatusCode != http.StatusBadRequest { t.Errorf("an unknown mode: expected 400, got %d", bad.StatusCode) } setSignupMode(t, s, "open") var info map[string]any decode(t, s.req(t, http.MethodGet, "/api/signup", nil), &info) if info["mode"] != "open" { t.Errorf("the change should be visible at once, got %v", info["mode"]) } } // Minting a link is configuring the team, so it is an owner's job. func TestSignup_InvitesAreOwnerOnly(t *testing.T) { s := newTS(t) _, call := member(t, s, "plain") resp := call(http.MethodPost, "/api/teams/"+defaultTeam+"/invites", map[string]any{"role": "member"}) resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Errorf("a member minting an invite: expected 403, got %d", resp.StatusCode) } } // A password still has to be a password, and a taken username is still taken. func TestSignup_ValidatesLikeTheRestOfTheServer(t *testing.T) { s := newTS(t) token := invite(t, s, "member", 5) short, _ := signup(t, s, map[string]any{ "username": "shorty", "email": "sh@test.com", "password": "abc", "invite": token, }) short.Body.Close() if short.StatusCode != http.StatusBadRequest { t.Errorf("a short password: expected 400, got %d", short.StatusCode) } taken, _ := signup(t, s, map[string]any{ "username": "admin", "email": "other@test.com", "password": "correct-horse-battery", "invite": token, }) taken.Body.Close() if taken.StatusCode != http.StatusConflict { t.Errorf("an existing username: expected 409, got %d", taken.StatusCode) } }