diff --git a/internal/core/dbtest_test.go b/internal/core/dbtest_test.go index 8711770..ef705ae 100644 --- a/internal/core/dbtest_test.go +++ b/internal/core/dbtest_test.go @@ -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 diff --git a/internal/core/endpoints_auth_test.go b/internal/core/endpoints_auth_test.go new file mode 100644 index 0000000..3e21fcf --- /dev/null +++ b/internal/core/endpoints_auth_test.go @@ -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")) + } +} diff --git a/internal/core/endpoints_org_test.go b/internal/core/endpoints_org_test.go new file mode 100644 index 0000000..d4bcac7 --- /dev/null +++ b/internal/core/endpoints_org_test.go @@ -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") + } +} diff --git a/internal/core/endpoints_repeater_test.go b/internal/core/endpoints_repeater_test.go new file mode 100644 index 0000000..0afeaae --- /dev/null +++ b/internal/core/endpoints_repeater_test.go @@ -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") +} diff --git a/internal/core/endpoints_shared_test.go b/internal/core/endpoints_shared_test.go new file mode 100644 index 0000000..3c375b1 --- /dev/null +++ b/internal/core/endpoints_shared_test.go @@ -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.) +}