b114251f45
Users get a notification_channels table (replacing users.ntfy_topic); pages fan
out to every enabled channel through a notify.Provider registry, with ntfy as
the first provider. Fallbacks are typed {type, address}. Schema edited in place:
existing databases must be recreated.
Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
328 lines
11 KiB
Go
328 lines
11 KiB
Go
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"
|
|
"git.ryuvia.com/niklas/terdut-server/internal/notify"
|
|
)
|
|
|
|
// 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=<token>
|
|
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 (handleTestChannel, which once
|
|
// queried the notification target of 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) {
|
|
// A provider set (even one pointing at an unreachable address) so the test
|
|
// endpoint reaches its owner check instead of short-circuiting on "no
|
|
// provider configured" first — this test is about the human-only check.
|
|
s := newTS(t, api.NotifyConfig{Providers: notify.NewRegistry(notify.NewNtfy("http://ntfy.invalid", ""))})
|
|
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
|
|
|
if resp := s.reqAs(t, instanceKey, http.MethodPost, "/api/users/1/channels/1/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()
|
|
}
|
|
}
|