mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-02 10:03:43 +00:00
Test code-testable endpoints
This commit is contained in:
@@ -2,6 +2,8 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -12,6 +14,32 @@ import (
|
||||
"github.com/jleight/meshtender/internal/testdb"
|
||||
)
|
||||
|
||||
// appLogin creates a user and returns it plus a live app-host session cookie,
|
||||
// established through the real /session/callback handoff. Endpoint tests pass the
|
||||
// returned cookie to post()/do() to drive authenticated app routes.
|
||||
func appLogin(t *testing.T, ts *httptest.Server, st *store.Store, ctx context.Context, host, username string) (*store.User, *http.Cookie) {
|
||||
t.Helper()
|
||||
u, err := st.CreateUser(ctx, username, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
loginID, err := st.CreateLogin(ctx, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create login: %v", err)
|
||||
}
|
||||
code, err := st.CreateAuthCode(ctx, u.ID, loginID, "/")
|
||||
if err != nil {
|
||||
t.Fatalf("create auth code: %v", err)
|
||||
}
|
||||
resp := do(t, ts, host, "/session/callback?code="+code+"&state=s1", &http.Cookie{Name: "mt_state", Value: "s1"})
|
||||
resp.Body.Close()
|
||||
c := cookieByName(resp, "meshtender_session")
|
||||
if c == nil {
|
||||
t.Fatalf("no app session cookie after handoff for %q", username)
|
||||
}
|
||||
return u, c
|
||||
}
|
||||
|
||||
// testConfig/testAuthConfig give the integration tests the app/auth/root hosts,
|
||||
// using the same constants as splitServer (testAuthHost/testAppHost/testRootHost/
|
||||
// testWWWHost, defined in handoff_test.go). Requests to a plain httptest listener
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Black-box coverage for the auth host's non-visual POST endpoints: password
|
||||
// sign-in/sign-up and the account-management forms. WebAuthn API ceremonies
|
||||
// (#20–25) need a virtual authenticator and are left to browser/manual checks.
|
||||
|
||||
// authSSO signs up a new user via the password form and returns its live SSO
|
||||
// session cookie — the automatable way to mint an authenticated auth-host session.
|
||||
func authSSO(t *testing.T, ts *httptest.Server, h hostEnv, username string) *http.Cookie {
|
||||
t.Helper()
|
||||
resp := post(t, ts, h.auth, "/signup/password", url.Values{"username": {username}, "password": {"supersecret"}})
|
||||
resp.Body.Close()
|
||||
c := cookieByName(resp, "meshtender_session")
|
||||
if c == nil {
|
||||
t.Fatalf("no SSO session cookie after signup for %q", username)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// #18 POST /login/password and #19 POST /signup/password.
|
||||
func TestPasswordSignupAndLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx, ts, h := splitServer(t)
|
||||
|
||||
// #19 signup, invalid (short password) → back to the form with an error.
|
||||
bad := post(t, ts, h.auth, "/signup/password", url.Values{"username": {"pwuser"}, "password": {"short"}})
|
||||
bad.Body.Close()
|
||||
if loc, _ := url.Parse(bad.Header.Get("Location")); bad.StatusCode != http.StatusSeeOther || loc.Path != "/signup" || loc.Query().Get("error") == "" {
|
||||
t.Fatalf("bad signup = %d %q, want 303 /signup?error", bad.StatusCode, bad.Header.Get("Location"))
|
||||
}
|
||||
|
||||
// #19 signup, valid → creates the account + an SSO session and hands off.
|
||||
ok := post(t, ts, h.auth, "/signup/password", url.Values{"username": {"pwuser"}, "password": {"supersecret"}})
|
||||
ok.Body.Close()
|
||||
if ok.StatusCode != http.StatusSeeOther || cookieByName(ok, "meshtender_session") == nil {
|
||||
t.Fatalf("valid signup = %d, session=%v; want 303 + session cookie", ok.StatusCode, cookieByName(ok, "meshtender_session"))
|
||||
}
|
||||
if _, err := st.GetUserByUsername(ctx, "pwuser"); err != nil {
|
||||
t.Fatalf("signup did not create the user: %v", err)
|
||||
}
|
||||
|
||||
// #18 login, wrong password → back to the form with an error.
|
||||
wrong := post(t, ts, h.auth, "/login/password", url.Values{"username": {"pwuser"}, "password": {"wrongpass"}})
|
||||
wrong.Body.Close()
|
||||
if loc, _ := url.Parse(wrong.Header.Get("Location")); wrong.StatusCode != http.StatusSeeOther || loc.Path != "/login" || loc.Query().Get("error") == "" {
|
||||
t.Fatalf("wrong login = %d %q, want 303 /login?error", wrong.StatusCode, wrong.Header.Get("Location"))
|
||||
}
|
||||
|
||||
// #18 login, correct credentials → session + handoff.
|
||||
good := post(t, ts, h.auth, "/login/password", url.Values{"username": {"pwuser"}, "password": {"supersecret"}})
|
||||
good.Body.Close()
|
||||
if good.StatusCode != http.StatusSeeOther || cookieByName(good, "meshtender_session") == nil {
|
||||
t.Fatalf("good login = %d, session=%v; want 303 + session cookie", good.StatusCode, cookieByName(good, "meshtender_session"))
|
||||
}
|
||||
}
|
||||
|
||||
// assertAccountOK asserts a 303 back to /account carrying a success ("ok") flash.
|
||||
func assertAccountOK(t *testing.T, resp *http.Response, label string) {
|
||||
t.Helper()
|
||||
loc, _ := url.Parse(resp.Header.Get("Location"))
|
||||
if resp.StatusCode != http.StatusSeeOther || loc.Path != "/account" {
|
||||
t.Fatalf("%s: %d %q, want 303 → /account", label, resp.StatusCode, resp.Header.Get("Location"))
|
||||
}
|
||||
if loc.Query().Get("ok") == "" {
|
||||
t.Fatalf("%s: %q, want an ok flash", label, resp.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// #29 profile, #100 profile-fields, #101 links, #30 password, #28 username.
|
||||
func TestAccountProfilePosts(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx, ts, h := splitServer(t)
|
||||
sso := authSSO(t, ts, h, "acctuser")
|
||||
|
||||
profile := post(t, ts, h.auth, "/account/profile", url.Values{"display_name": {"Ada Lovelace"}}, sso)
|
||||
profile.Body.Close()
|
||||
assertAccountOK(t, profile, "profile")
|
||||
|
||||
fields := post(t, ts, h.auth, "/account/profile-fields",
|
||||
url.Values{"bio": {"hi there"}, "location": {"NYC"}, "callsign": {"W1AW"}}, sso)
|
||||
fields.Body.Close()
|
||||
assertAccountOK(t, fields, "profile-fields")
|
||||
|
||||
links := post(t, ts, h.auth, "/account/links",
|
||||
url.Values{"link_platform": {"website"}, "link_label": {"Site"}, "link_url": {"https://example.com"}}, sso)
|
||||
links.Body.Close()
|
||||
assertAccountOK(t, links, "links")
|
||||
|
||||
pw := post(t, ts, h.auth, "/account/password",
|
||||
url.Values{"new_password": {"anothersecret"}, "current_password": {"supersecret"}}, sso)
|
||||
pw.Body.Close()
|
||||
assertAccountOK(t, pw, "password")
|
||||
|
||||
// Username change last (it changes the identity). A fresh account has no prior
|
||||
// self-change, so the 30-day cooldown doesn't apply.
|
||||
uname := post(t, ts, h.auth, "/account/username", url.Values{"username": {"renamed-acct"}}, sso)
|
||||
uname.Body.Close()
|
||||
assertAccountOK(t, uname, "username")
|
||||
if _, err := st.GetUserByUsername(ctx, "renamed-acct"); err != nil {
|
||||
t.Fatalf("username was not changed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// #31 passkey rename, #32 passkey delete. The user has no passkeys, but the
|
||||
// endpoints must still resolve cleanly back to /account (they don't render).
|
||||
func TestAccountPasskeyPosts(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, ts, h := splitServer(t)
|
||||
sso := authSSO(t, ts, h, "pkuser")
|
||||
|
||||
rn := post(t, ts, h.auth, "/account/passkeys/rename",
|
||||
url.Values{"credential_id": {"1"}, "name": {"Phone"}}, sso)
|
||||
rn.Body.Close()
|
||||
if loc, _ := url.Parse(rn.Header.Get("Location")); rn.StatusCode != http.StatusSeeOther || loc.Path != "/account" {
|
||||
t.Fatalf("passkey rename = %d %q, want 303 → /account", rn.StatusCode, rn.Header.Get("Location"))
|
||||
}
|
||||
|
||||
// The user still has a password, so removing a (nonexistent) passkey is allowed.
|
||||
del := post(t, ts, h.auth, "/account/passkeys/delete", url.Values{"credential_id": {"1"}}, sso)
|
||||
del.Body.Close()
|
||||
if loc, _ := url.Parse(del.Header.Get("Location")); del.StatusCode != http.StatusSeeOther || loc.Path != "/account" {
|
||||
t.Fatalf("passkey delete = %d %q, want 303 → /account", del.StatusCode, del.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Black-box coverage for the org-management POST endpoints (create/edit/links/
|
||||
// members/my-commands and join/leave). Each asserts the 303 redirect target and a
|
||||
// cheap store side-effect; none render anything.
|
||||
|
||||
// #43 create, #44 edit, #104 links, #48 member role, #50 my-commands.
|
||||
func TestOrgManagementPosts(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx, ts, h := splitServer(t)
|
||||
_, sess := appLogin(t, ts, st, ctx, h.app, "orgadmin")
|
||||
|
||||
// #43 create → 303 to the new org's page.
|
||||
create := post(t, ts, h.app, "/orgs", url.Values{"name": {"Test Org"}}, sess)
|
||||
create.Body.Close()
|
||||
loc, _ := url.Parse(create.Header.Get("Location"))
|
||||
if create.StatusCode != http.StatusSeeOther || !strings.HasPrefix(loc.Path, "/orgs/") {
|
||||
t.Fatalf("create org = %d %q, want 303 → /orgs/{slug}", create.StatusCode, create.Header.Get("Location"))
|
||||
}
|
||||
slug := strings.TrimPrefix(loc.Path, "/orgs/")
|
||||
|
||||
// #44 edit (renames the slug) → 303 to the new canonical URL.
|
||||
edit := post(t, ts, h.app, "/orgs/"+slug+"/edit",
|
||||
url.Values{"name": {"Renamed Org"}, "slug": {"renamed-org"}, "description": {"desc"}, "region": {"NA"}}, sess)
|
||||
edit.Body.Close()
|
||||
if loc, _ := url.Parse(edit.Header.Get("Location")); edit.StatusCode != http.StatusSeeOther || loc.Path != "/orgs/renamed-org" {
|
||||
t.Fatalf("edit org = %d %q, want 303 → /orgs/renamed-org", edit.StatusCode, edit.Header.Get("Location"))
|
||||
}
|
||||
slug = "renamed-org"
|
||||
|
||||
// #104 links → 303 back to the org page.
|
||||
links := post(t, ts, h.app, "/orgs/"+slug+"/links",
|
||||
url.Values{"link_platform": {"website"}, "link_label": {"Home"}, "link_url": {"https://example.org"}}, sess)
|
||||
links.Body.Close()
|
||||
if loc, _ := url.Parse(links.Header.Get("Location")); links.StatusCode != http.StatusSeeOther || loc.Path != "/orgs/"+slug {
|
||||
t.Fatalf("set org links = %d %q, want 303 → /orgs/%s", links.StatusCode, links.Header.Get("Location"), slug)
|
||||
}
|
||||
|
||||
// #48 member role — promote a second member to admin.
|
||||
orgID, ok := func() (int64, bool) { id, err := st.OrgIDBySlug(ctx, slug); return id, err == nil }()
|
||||
if !ok {
|
||||
t.Fatal("could not resolve renamed org slug")
|
||||
}
|
||||
other, err := st.CreateUser(ctx, "orgmember", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.AddOrgMember(ctx, orgID, other.ID, "member"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
promote := post(t, ts, h.app, "/orgs/"+slug+"/members/"+strconv.FormatInt(other.ID, 10),
|
||||
url.Values{"action": {"promote"}}, sess)
|
||||
promote.Body.Close()
|
||||
if loc, _ := url.Parse(promote.Header.Get("Location")); promote.StatusCode != http.StatusSeeOther || loc.Path != "/orgs/"+slug+"/members" {
|
||||
t.Fatalf("promote member = %d %q, want 303 → members", promote.StatusCode, promote.Header.Get("Location"))
|
||||
}
|
||||
if admin, _ := st.IsOrgAdmin(ctx, orgID, other.ID); !admin {
|
||||
t.Fatal("promote did not make the member an admin")
|
||||
}
|
||||
|
||||
// #50 my-commands — "clear" removes any restriction, redirecting to the editor.
|
||||
cmds := post(t, ts, h.app, "/orgs/"+slug+"/my-commands", url.Values{"clear": {"1"}}, sess)
|
||||
cmds.Body.Close()
|
||||
if loc, _ := url.Parse(cmds.Header.Get("Location")); cmds.StatusCode != http.StatusSeeOther || loc.Path != "/orgs/"+slug+"/my-commands" {
|
||||
t.Fatalf("save my-commands = %d %q, want 303 → my-commands", cmds.StatusCode, cmds.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// #56 update config profile, #57 delete config profile. (Create #54 and regions
|
||||
// #59 are covered by TestOrgConfigProfilesFlow.)
|
||||
func TestOrgConfigProfileUpdateDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx, ts, h := splitServer(t)
|
||||
owner, sess := appLogin(t, ts, st, ctx, h.app, "cfgadmin")
|
||||
org, err := st.CreateOrg(ctx, "Cfg Org", owner.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pid, err := st.CreateProfile(ctx, org.ID, "ESP32", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := "/orgs/" + org.Slug + "/config"
|
||||
|
||||
upd := post(t, ts, h.app, base+"/profiles/"+strconv.FormatInt(pid, 10),
|
||||
url.Values{"profile_name": {"Heltec"}, "profile_steps": {"# base"}}, sess)
|
||||
upd.Body.Close()
|
||||
if loc, _ := url.Parse(upd.Header.Get("Location")); upd.StatusCode != http.StatusSeeOther || loc.Path != base+"/edit" {
|
||||
t.Fatalf("update profile = %d %q, want 303 → config/edit", upd.StatusCode, upd.Header.Get("Location"))
|
||||
}
|
||||
|
||||
del := post(t, ts, h.app, base+"/profiles/"+strconv.FormatInt(pid, 10)+"/delete", url.Values{}, sess)
|
||||
del.Body.Close()
|
||||
if loc, _ := url.Parse(del.Header.Get("Location")); del.StatusCode != http.StatusSeeOther || loc.Path != base+"/edit" {
|
||||
t.Fatalf("delete profile = %d %q, want 303 → config/edit", del.StatusCode, del.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// #46 join, #47 leave — against an org owned by someone else.
|
||||
func TestOrgJoinLeavePosts(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx, ts, h := splitServer(t)
|
||||
owner, err := st.CreateUser(ctx, "otherowner", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
org, err := st.CreateOrg(ctx, "Joinable Org", owner.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joiner, sess := appLogin(t, ts, st, ctx, h.app, "joiner")
|
||||
|
||||
join := post(t, ts, h.app, "/orgs/"+org.Slug+"/join", url.Values{}, sess)
|
||||
join.Body.Close()
|
||||
if loc, _ := url.Parse(join.Header.Get("Location")); join.StatusCode != http.StatusSeeOther || loc.Path != "/orgs/"+org.Slug {
|
||||
t.Fatalf("join = %d %q, want 303 → /orgs/%s", join.StatusCode, join.Header.Get("Location"), org.Slug)
|
||||
}
|
||||
if _, isMember, _ := st.OrgRole(ctx, org.ID, joiner.ID); !isMember {
|
||||
t.Fatal("join did not add membership")
|
||||
}
|
||||
|
||||
leave := post(t, ts, h.app, "/orgs/"+org.Slug+"/leave", url.Values{}, sess)
|
||||
leave.Body.Close()
|
||||
if loc, _ := url.Parse(leave.Header.Get("Location")); leave.StatusCode != http.StatusSeeOther || loc.Path != "/orgs" {
|
||||
t.Fatalf("leave = %d %q, want 303 → /orgs", leave.StatusCode, leave.Header.Get("Location"))
|
||||
}
|
||||
if _, isMember, _ := st.OrgRole(ctx, org.ID, joiner.ID); isMember {
|
||||
t.Fatal("leave did not remove membership")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
meshcore "github.com/meshcore-go/meshcore-go"
|
||||
|
||||
"github.com/jleight/meshtender/internal/store"
|
||||
)
|
||||
|
||||
// Black-box coverage for the repeater, sharing, invite, and admin POST endpoints.
|
||||
// All redirect (303) and do not render; each test asserts the redirect target and,
|
||||
// where cheap, a store side-effect.
|
||||
|
||||
// newOwnedRepeater creates a repeater owned by ownerID with a valid MeshCore key.
|
||||
func newOwnedRepeater(t *testing.T, st *store.Store, ctx context.Context, ownerID int64, name string) *store.Repeater {
|
||||
t.Helper()
|
||||
id, err := meshcore.GenerateLocalIdentity(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate identity: %v", err)
|
||||
}
|
||||
rep, err := st.CreateRepeater(ctx, &store.Repeater{
|
||||
OwnerID: ownerID, Name: name, PublicKeyHex: id.String(),
|
||||
RadioFreqHz: 869525000, RadioBwHz: 250000, RadioSF: 11, RadioCR: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create repeater: %v", err)
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
// assertRedirect asserts resp is a 303 whose Location path equals want.
|
||||
func assertRedirect(t *testing.T, resp *http.Response, want, label string) {
|
||||
t.Helper()
|
||||
loc, _ := url.Parse(resp.Header.Get("Location"))
|
||||
if resp.StatusCode != http.StatusSeeOther || loc.Path != want {
|
||||
t.Fatalf("%s = %d %q, want 303 → %s", label, resp.StatusCode, resp.Header.Get("Location"), want)
|
||||
}
|
||||
}
|
||||
|
||||
// #63 create, #67 edit, #76 docs, #78 add + #79 delete maintenance, #69 delete.
|
||||
func TestRepeaterCrudPosts(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx, ts, h := splitServer(t)
|
||||
owner, sess := appLogin(t, ts, st, ctx, h.app, "reptester")
|
||||
|
||||
// #63 create → /repeaters/{id}/added
|
||||
id, _ := meshcore.GenerateLocalIdentity(rand.Reader)
|
||||
create := post(t, ts, h.app, "/repeaters", url.Values{
|
||||
"name": {"New Rep"}, "public_key": {id.String()},
|
||||
"radio_freq_mhz": {"869.525"}, "radio_bw_khz": {"250"}, "radio_sf": {"11"}, "radio_cr": {"5"},
|
||||
}, sess)
|
||||
create.Body.Close()
|
||||
loc, _ := url.Parse(create.Header.Get("Location"))
|
||||
if create.StatusCode != http.StatusSeeOther || loc.Path == "" || loc.Path[len(loc.Path)-6:] != "/added" {
|
||||
t.Fatalf("create repeater = %d %q, want 303 → /repeaters/{id}/added", create.StatusCode, create.Header.Get("Location"))
|
||||
}
|
||||
|
||||
rep := newOwnedRepeater(t, st, ctx, owner.ID, "Edit Me")
|
||||
pid := rep.PublicID
|
||||
|
||||
edit := post(t, ts, h.app, "/repeaters/"+pid+"/edit", url.Values{
|
||||
"name": {"Edited"}, "radio_freq_mhz": {"869.525"}, "radio_bw_khz": {"250"}, "radio_sf": {"11"}, "radio_cr": {"5"},
|
||||
}, sess)
|
||||
edit.Body.Close()
|
||||
assertRedirect(t, edit, "/", "edit repeater")
|
||||
|
||||
docs := post(t, ts, h.app, "/repeaters/"+pid+"/docs",
|
||||
url.Values{"doc_public": {"public notes"}, "doc_internal": {"internal notes"}}, sess)
|
||||
docs.Body.Close()
|
||||
assertRedirect(t, docs, "/repeaters/"+pid+"/docs", "save docs")
|
||||
|
||||
maint := post(t, ts, h.app, "/repeaters/"+pid+"/maintenance", url.Values{"note": {"swapped antenna"}}, sess)
|
||||
maint.Body.Close()
|
||||
assertRedirect(t, maint, "/repeaters/"+pid+"/maintenance", "add maintenance")
|
||||
|
||||
entries, err := st.ListMaintenance(ctx, rep.ID)
|
||||
if err != nil || len(entries) == 0 {
|
||||
t.Fatalf("list maintenance: %v (n=%d)", err, len(entries))
|
||||
}
|
||||
del := post(t, ts, h.app, "/repeaters/"+pid+"/maintenance/delete",
|
||||
url.Values{"entry_id": {strconv.FormatInt(entries[0].ID, 10)}}, sess)
|
||||
del.Body.Close()
|
||||
assertRedirect(t, del, "/repeaters/"+pid+"/maintenance", "delete maintenance")
|
||||
|
||||
delRep := post(t, ts, h.app, "/repeaters/"+pid+"/delete", url.Values{}, sess)
|
||||
delRep.Body.Close()
|
||||
assertRedirect(t, delRep, "/", "delete repeater")
|
||||
}
|
||||
|
||||
// #81 create link, #82 delete link, #85 share commands, #86 steward, #83 unshare,
|
||||
// #87 org participation.
|
||||
func TestRepeaterSharePosts(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx, ts, h := splitServer(t)
|
||||
owner, sess := appLogin(t, ts, st, ctx, h.app, "shareowner")
|
||||
rep := newOwnedRepeater(t, st, ctx, owner.ID, "Shared Rep")
|
||||
pid := rep.PublicID
|
||||
share := "/repeaters/" + pid + "/share"
|
||||
|
||||
link := post(t, ts, h.app, share+"/link", url.Values{"description": {"friends"}}, sess)
|
||||
link.Body.Close()
|
||||
assertRedirect(t, link, share, "create share link")
|
||||
|
||||
invites, err := st.ListInvites(ctx, rep.ID)
|
||||
if err != nil || len(invites) == 0 {
|
||||
t.Fatalf("list invites: %v (n=%d)", err, len(invites))
|
||||
}
|
||||
dl := post(t, ts, h.app, share+"/link/delete", url.Values{"invite_id": {strconv.FormatInt(invites[0].ID, 10)}}, sess)
|
||||
dl.Body.Close()
|
||||
assertRedirect(t, dl, share, "delete share link")
|
||||
|
||||
// A directly-added share to a second user, for the per-share endpoints.
|
||||
target, err := st.CreateUser(ctx, "sharee", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.AddShare(ctx, rep.ID, target.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tid := strconv.FormatInt(target.ID, 10)
|
||||
|
||||
cmds := post(t, ts, h.app, share+"/"+tid+"/commands", url.Values{}, sess) // empty = no commands
|
||||
cmds.Body.Close()
|
||||
assertRedirect(t, cmds, share, "set share commands")
|
||||
|
||||
stew := post(t, ts, h.app, share+"/"+tid+"/steward", url.Values{"steward": {"1"}}, sess)
|
||||
stew.Body.Close()
|
||||
assertRedirect(t, stew, share, "set steward")
|
||||
|
||||
un := post(t, ts, h.app, "/repeaters/"+pid+"/unshare", url.Values{"user_id": {tid}}, sess)
|
||||
un.Body.Close()
|
||||
assertRedirect(t, un, share, "unshare")
|
||||
|
||||
// #87 participation: exclude this repeater from an org the owner belongs to.
|
||||
org, err := st.CreateOrg(ctx, "Participation Org", owner.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The {orgID} route param is the org slug, not the numeric id.
|
||||
part := post(t, ts, h.app, "/repeaters/"+pid+"/orgs/"+org.Slug+"/participation",
|
||||
url.Values{"action": {"exclude"}}, sess)
|
||||
part.Body.Close()
|
||||
assertRedirect(t, part, share, "org participation")
|
||||
}
|
||||
|
||||
// #88 POST /invite/{token}/accept — a second user redeems a share link.
|
||||
func TestAcceptInvitePost(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx, ts, h := splitServer(t)
|
||||
owner, err := st.CreateUser(ctx, "invowner", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rep := newOwnedRepeater(t, st, ctx, owner.ID, "Invite Rep")
|
||||
token, err := st.CreateInvite(ctx, rep.ID, "come join")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
invitee, sess := appLogin(t, ts, st, ctx, h.app, "invitee")
|
||||
|
||||
acc := post(t, ts, h.app, "/invite/"+token+"/accept", url.Values{}, sess)
|
||||
acc.Body.Close()
|
||||
assertRedirect(t, acc, "/", "accept invite")
|
||||
|
||||
// The invitee now sees the repeater in their list (shared).
|
||||
reps, err := st.ListRepeatersForUser(ctx, invitee.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, rp := range reps {
|
||||
if rp.ID == rep.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("accepting the invite did not share the repeater with the invitee")
|
||||
}
|
||||
}
|
||||
|
||||
// #92 catalog update, #95 set user capabilities. Both require an admin cap, which
|
||||
// the session picks up live from the store on the next request.
|
||||
func TestAdminPosts(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx, ts, h := splitServer(t)
|
||||
admin, sess := appLogin(t, ts, st, ctx, h.app, "superadmin")
|
||||
if err := st.SetCapabilities(ctx, admin.ID, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmds, err := st.ListCommands(ctx)
|
||||
if err != nil || len(cmds) == 0 {
|
||||
t.Fatalf("list commands: %v (n=%d)", err, len(cmds))
|
||||
}
|
||||
cat := post(t, ts, h.app, "/admin/catalog/"+strconv.FormatInt(cmds[0].ID, 10), url.Values{}, sess)
|
||||
cat.Body.Close()
|
||||
assertRedirect(t, cat, "/admin/catalog", "catalog update")
|
||||
|
||||
target, err := st.CreateUser(ctx, "capuser", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setCaps := post(t, ts, h.app, "/admin/users/"+strconv.FormatInt(target.ID, 10),
|
||||
url.Values{"manage_catalog": {"1"}}, sess)
|
||||
setCaps.Body.Close()
|
||||
assertRedirect(t, setCaps, "/admin/users", "set user caps")
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// These black-box tests cover the non-visual "plumbing" endpoints from the
|
||||
// endpoint inventory (docs/endpoint-inventory.md): health, static assets, and the
|
||||
// pure host/redirect behaviors. They use the splitServer harness (real three-host
|
||||
// server + real Postgres) and assert status/redirect Location rather than any
|
||||
// rendered UI.
|
||||
|
||||
// #1 /healthz — returns "ok" on every surface.
|
||||
func TestHealthzEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, ts, h := splitServer(t)
|
||||
for _, host := range []string{h.app, h.auth, h.root} {
|
||||
resp := do(t, ts, host, "/healthz")
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || strings.TrimSpace(string(body)) != "ok" {
|
||||
t.Fatalf("%s/healthz = %d %q, want 200 \"ok\"", host, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// #2 /static/* — serves an embedded asset.
|
||||
func TestStaticAssetEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, ts, h := splitServer(t)
|
||||
resp := do(t, ts, h.app, "/static/ui.js")
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("/static/ui.js = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "javascript") {
|
||||
t.Fatalf("/static/ui.js Content-Type = %q, want a javascript type", ct)
|
||||
}
|
||||
if len(body) == 0 {
|
||||
t.Fatal("/static/ui.js served an empty body")
|
||||
}
|
||||
}
|
||||
|
||||
// #15 auth host `/` — bare visits 303 to the sign-in page.
|
||||
func TestAuthRootRedirectsToLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, ts, h := splitServer(t)
|
||||
resp := do(t, ts, h.auth, "/")
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("auth / = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
if loc, _ := url.Parse(resp.Header.Get("Location")); loc.Path != "/login" {
|
||||
t.Fatalf("auth / → %q, want /login", resp.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// #35 app host `/signup` — starts the signup handoff, bouncing to the auth host.
|
||||
func TestAppSignupRedirectsToAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, ts, h := splitServer(t)
|
||||
resp := do(t, ts, h.app, "/signup")
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("app /signup = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
if loc, _ := url.Parse(resp.Header.Get("Location")); loc.Host != h.auth || loc.Path != "/signup" {
|
||||
t.Fatalf("app /signup → %q, want auth host /signup", resp.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// #97 custom org domain — a verified domain serves the org's public page at `/`
|
||||
// and 302-redirects every other path to the app host.
|
||||
func TestCustomDomainRedirect(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx, ts, _ := splitServer(t)
|
||||
|
||||
owner, err := st.CreateUser(ctx, "domainowner", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
org, err := st.CreateOrg(ctx, "Domain Org", owner.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const customHost = "mesh.example.org"
|
||||
dom, err := st.CreateOrgDomain(ctx, org.ID, customHost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.MarkOrgDomainVerified(ctx, org.ID, dom.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Non-root path on the custom host → 302 to the same path on the app host.
|
||||
resp := do(t, ts, customHost, "/repeaters/abc")
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("custom-domain /repeaters/abc = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
if loc, _ := url.Parse(resp.Header.Get("Location")); loc.Hostname() != testAppHost || loc.Path != "/repeaters/abc" {
|
||||
t.Fatalf("custom-domain redirect = %q, want app host /repeaters/abc", resp.Header.Get("Location"))
|
||||
}
|
||||
// (The custom-domain `/` org page — inventory #96 — is a rendered page left to
|
||||
// manual/browser verification.)
|
||||
}
|
||||
Reference in New Issue
Block a user