package api_test import ( "bytes" "database/sql" "encoding/json" "net/http" "net/http/cookiejar" "testing" "git.ryuvia.com/niklas/terdut-server/internal/api" "git.ryuvia.com/niklas/terdut-server/internal/models" ) // 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) } } // A team-scoped service account has no users row to attribute created_by to. // Before this fix, handleCreateInvite wrote its zero-value caller.ID straight // into that (nullable, ON DELETE SET NULL) foreign key instead of leaving it // NULL the way handleCreateServiceAccount already does for the same // situation — a 500, not the 201 TestServiceAccount_TeamScopeManagesItsOwnInvites // now confirms. This pins the column itself ends up NULL, not just "some // response came back". func TestSignup_InviteCreatedByAServiceAccountLeavesCreatedByNull(t *testing.T) { s := newTS(t) instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0) teamA := createTeamAs(t, s, instanceKey, "team-a") keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA) var created struct { ID int64 `json:"id"` } decode(t, s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/invites", map[string]any{}), &created) var createdBy sql.NullInt64 if err := s.db.QueryRow("SELECT created_by FROM invites WHERE id = $1", created.ID).Scan(&createdBy); err != nil { t.Fatalf("read back invites.created_by: %v", err) } if createdBy.Valid { t.Errorf("expected created_by to be NULL for a service-account-minted invite, got %d", createdBy.Int64) } } // Neither of these has a real user_id to act on behalf of; both must 403 a // service account explicitly rather than 500 (handleTestNotification, which // used to query ntfy_topic for user id 0) or silently no-op (handleDismissOnboarding, // which used to UPDATE ... WHERE id = 0, affecting nothing and still // returning 204). func TestServiceAccount_HumanOnlyEndpointsRefuseExplicitly(t *testing.T) { // BaseURL set (even to a fake, unreachable address) so handleTestNotification // reaches its AsHuman() check instead of short-circuiting on "ntfy not // configured" first — this test is about the human-only check, not ntfy. s := newTS(t, api.NotifyConfig{BaseURL: "http://ntfy.invalid"}) instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0) if resp := s.reqAs(t, instanceKey, http.MethodPost, "/api/me/notify/test", nil); resp.StatusCode != http.StatusForbidden { t.Errorf("expected 403 for a service account testing notifications, got %d", resp.StatusCode) } else { resp.Body.Close() } if resp := s.reqAs(t, instanceKey, http.MethodPut, "/api/me/onboarding", map[string]bool{"dismissed": true}); resp.StatusCode != http.StatusForbidden { t.Errorf("expected 403 for a service account dismissing onboarding, got %d", resp.StatusCode) } else { resp.Body.Close() } }