Files
Rogee ea6811b295
Build and push backend image / backend-image (push) Successful in 45s
fix: use verified WeChat login and recover sessions after cache clearing
2026-09-26 18:47:38 +08:00

816 lines
31 KiB
Go

package httpapi
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gofiber/fiber/v3"
"github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/config"
"github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/service"
"github.com/sirupsen/logrus"
_ "modernc.org/sqlite"
)
const testAdminSessionSecret = "0123456789abcdef0123456789abcdef"
func TestAdminLoginProtectsPagesAndRendersPriceInquiries(t *testing.T) {
app, closeDB := newTestAdminAppWithConfig(t, config.Config{
AdminUsername: "test-admin",
AdminPassword: "test-password",
AdminSessionSecret: testAdminSessionSecret,
})
defer closeDB()
resp := doRequest(t, app, http.MethodGet, "/admin/price-inquiries", "", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusSeeOther)
}
if location := resp.Header.Get("Location"); !strings.HasPrefix(location, "/admin/login?next=") {
t.Fatalf("Location = %q, want login redirect", location)
}
resp = doRequest(t, app, http.MethodGet, "/admin/projects", "", map[string]string{"Accept": "application/json"})
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusUnauthorized)
}
cookie := adminSessionCookieHeader(config.Config{
AdminSessionSecret: testAdminSessionSecret,
}, adminUser{Subject: "test-admin", DisplayName: "王医生"})
resp = doRequest(t, app, http.MethodGet, "/admin/", "", map[string]string{
"Cookie": cookie,
"Accept": "text/html",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("admin root status = %d, want %d", resp.StatusCode, http.StatusSeeOther)
}
if location := resp.Header.Get("Location"); location != defaultAdminPage {
t.Fatalf("admin root Location = %q, want %q", location, defaultAdminPage)
}
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries", "", map[string]string{
"Cookie": cookie,
"Accept": "text/html",
})
defer resp.Body.Close()
body := readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
}
if !strings.Contains(body, "价格咨询派单") || !strings.Contains(body, "创建时间") || !strings.Contains(body, "最近提交时间") || !strings.Contains(body, "备注") || !strings.Contains(body, "设置") {
t.Fatalf("price inquiries page did not render expected columns: %s", body)
}
for _, expected := range []string{
"共 0 条咨询,第 1 / 1 页",
`<select id="page_size" name="page_size">`,
`<option value="50" selected>50 条/页</option>`,
`<span class="button secondary small disabled">上一页</span>`,
`<span class="button secondary small disabled">下一页</span>`,
} {
if !strings.Contains(body, expected) {
t.Fatalf("price inquiries page missing pagination element %q: %s", expected, body)
}
}
if strings.Contains(body, "<h1>价格咨询派单</h1>") || strings.Contains(body, "已授权手机号提交的地区和咨询项目") || strings.Contains(body, "咨询单 ID") {
t.Fatalf("price inquiries page rendered removed title or ID column: %s", body)
}
if !strings.Contains(body, "王医生") {
t.Fatalf("price inquiries page did not render admin name: %s", body)
}
if strings.Contains(body, "/admin/phones") || strings.Contains(body, "/admin/booking-projects") || strings.Contains(body, "/admin/project-config") {
t.Fatalf("price inquiries page should not render removed module links: %s", body)
}
if strings.Contains(body, "修改密码") || strings.Contains(body, "OIDC") {
t.Fatalf("price inquiries page rendered removed OIDC controls: %s", body)
}
}
func TestAdminLoginPageRendersCredentialForm(t *testing.T) {
app, closeDB := newTestAdminAppWithConfig(t, config.Config{
AdminUsername: "operator",
AdminPassword: "local-password",
AdminSessionSecret: testAdminSessionSecret,
})
defer closeDB()
resp := doRequest(t, app, http.MethodGet, "/admin/login?next=%2Fadmin%2Fprice-inquiries", "", map[string]string{
"Accept": "text/html",
})
defer resp.Body.Close()
body := readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
}
for _, expected := range []string{`action="/admin/login"`, `name="username"`, `name="password"`, `name="next"`} {
if !strings.Contains(body, expected) {
t.Fatalf("login page missing %q: %s", expected, body)
}
}
if strings.Contains(body, "OIDC") || strings.Contains(body, "/admin/oidc/") {
t.Fatalf("login page rendered removed OIDC controls: %s", body)
}
}
func TestAdminLoginAcceptsCredentialsAndSetsSession(t *testing.T) {
app, closeDB := newTestAdminAppWithConfig(t, config.Config{
AdminUsername: "operator",
AdminPassword: "local-password",
AdminSessionSecret: testAdminSessionSecret,
})
defer closeDB()
invalid := url.Values{"username": {"operator"}, "password": {"wrong"}, "next": {"/admin"}}
resp := doRequest(t, app, http.MethodPost, "/admin/login", invalid.Encode(), map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
})
body := readBody(t, resp)
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized || !strings.Contains(body, "用户名或密码错误") {
t.Fatalf("invalid login status/body = %d/%s, want 401 with generic error", resp.StatusCode, body)
}
if resp.Header.Get("Set-Cookie") != "" {
t.Fatalf("invalid login issued a cookie: %s", resp.Header.Get("Set-Cookie"))
}
valid := url.Values{"username": {"operator"}, "password": {"local-password"}, "next": {"/admin/price-inquiries"}}
resp = doRequest(t, app, http.MethodPost, "/admin/login", valid.Encode(), map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/admin/price-inquiries" {
t.Fatalf("valid login status/location = %d/%q, want redirect to admin page", resp.StatusCode, resp.Header.Get("Location"))
}
var session *http.Cookie
for _, cookie := range resp.Cookies() {
if cookie.Name == adminSessionCookie {
session = cookie
break
}
}
if session == nil || !session.HttpOnly || session.Path != "/admin" || session.SameSite != http.SameSiteLaxMode {
t.Fatalf("invalid admin session cookie: %#v", session)
}
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries", "", map[string]string{
"Cookie": session.Name + "=" + session.Value,
"Accept": "text/html",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("authenticated page status = %d, want %d", resp.StatusCode, http.StatusOK)
}
}
func TestAdminLoginRateLimit(t *testing.T) {
app, closeDB := newTestAdminApp(t)
defer closeDB()
body := url.Values{"username": {"wrong"}, "password": {"wrong"}}.Encode()
for attempt := 1; attempt <= 6; attempt++ {
resp := doRequest(t, app, http.MethodPost, "/admin/login", body, map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
})
resp.Body.Close()
want := http.StatusUnauthorized
if attempt == 6 {
want = http.StatusTooManyRequests
}
if resp.StatusCode != want {
t.Fatalf("attempt %d status = %d, want %d", attempt, resp.StatusCode, want)
}
}
}
func TestAdminRemovedModuleRoutesReturnNotFound(t *testing.T) {
app, closeDB := newTestAdminApp(t)
defer closeDB()
cookie := loginAdmin(t, app)
for _, route := range []struct {
method string
path string
body string
}{
{method: http.MethodGet, path: "/api/app/bootstrap"},
{method: http.MethodGet, path: "/api/projects"},
{method: http.MethodGet, path: "/api/bookings"},
{method: http.MethodPost, path: "/api/bookings", body: "{}"},
{method: http.MethodGet, path: "/api/bookings/old"},
{method: http.MethodPatch, path: "/api/bookings/old/cancel", body: "{}"},
{method: http.MethodGet, path: "/admin/oidc/start"},
{method: http.MethodGet, path: "/admin/oidc/callback"},
{method: http.MethodGet, path: "/admin/phones"},
{method: http.MethodGet, path: "/admin/categories"},
{method: http.MethodPost, path: "/admin/categories", body: "{}"},
{method: http.MethodGet, path: "/admin/projects"},
{method: http.MethodPost, path: "/admin/projects", body: "{}"},
{method: http.MethodGet, path: "/admin/schedules"},
{method: http.MethodPost, path: "/admin/schedules/days", body: "{}"},
{method: http.MethodPatch, path: "/admin/schedules/days/day", body: "{}"},
{method: http.MethodPost, path: "/admin/schedules/slots", body: "{}"},
{method: http.MethodPatch, path: "/admin/schedules/slots/slot", body: "{}"},
{method: http.MethodGet, path: "/admin/bookings"},
{method: http.MethodPatch, path: "/admin/bookings/booking/status", body: "{}"},
{method: http.MethodGet, path: "/admin/reports/bookings"},
{method: http.MethodGet, path: "/admin/booking-projects"},
{method: http.MethodGet, path: "/admin/project-config"},
{method: http.MethodPost, path: "/admin/project-config/projects", body: "id=project_fresh_clean"},
} {
resp := doRequest(t, app, route.method, route.path, route.body, map[string]string{
"Cookie": cookie,
"Accept": "text/html",
"Content-Type": "application/json",
})
body := readBody(t, resp)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("%s %s status = %d, want %d: %s", route.method, route.path, resp.StatusCode, http.StatusNotFound, body)
}
}
}
func TestPriceInquirySavesRegionAndAppearsInAdminDispatchList(t *testing.T) {
app, closeDB := newTestAdminApp(t)
defer closeDB()
resp := doRequest(t, app, http.MethodPost, "/api/auth/wechat/session", `{"code":"price_inquiry_test"}`, map[string]string{
"Content-Type": "application/json",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("session status = %d, want %d: %s", resp.StatusCode, http.StatusOK, readBody(t, resp))
}
var session struct {
User struct {
Openid string `json:"openid"`
} `json:"user"`
}
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
t.Fatalf("decode session: %v", err)
}
resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"`+session.User.Openid+`","phone":"13900005555"}`, map[string]string{
"Content-Type": "application/json",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("phone bind status = %d, want %d: %s", resp.StatusCode, http.StatusOK, readBody(t, resp))
}
resp = doRequest(t, app, http.MethodPost, "/api/price-inquiries", `{"openid":"`+session.User.Openid+`","province":"上海市","city":"上海市","district":"浦东新区","project_name":"半月板损伤"}`, map[string]string{
"Content-Type": "application/json",
})
defer resp.Body.Close()
body := readBody(t, resp)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusCreated, body)
}
var createdInquiry struct {
ID string `json:"id"`
Status string `json:"status"`
}
if err := json.Unmarshal([]byte(body), &createdInquiry); err != nil {
t.Fatalf("decode price inquiry: %v", err)
}
if createdInquiry.ID == "" || createdInquiry.Status != "pending" {
t.Fatalf("price inquiry = %+v, want id and pending status", createdInquiry)
}
resp = doRequest(t, app, http.MethodPost, "/api/price-inquiries", `{"openid":"`+session.User.Openid+`","province":"上海市","city":"上海市","district":"浦东新区","project_name":"半月板损伤"}`, map[string]string{
"Content-Type": "application/json",
})
defer resp.Body.Close()
body = readBody(t, resp)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("duplicate price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusCreated, body)
}
if !strings.Contains(body, `"updated_at":`) {
t.Fatalf("duplicate price inquiry body = %s, want updated_at", body)
}
cookie := loginAdmin(t, app)
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?area=浦东&phone=13900005555&time_from=2000-01-01&time_to=2999-12-31", "", map[string]string{
"Cookie": cookie,
"Accept": "text/html",
})
defer resp.Body.Close()
body = readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
}
for _, expected := range []string{
"13900005555",
"半月板损伤",
"上海市 上海市 浦东新区",
"待派单",
"<th>创建时间</th>",
"<th>最近提交时间</th>",
"<th>更新时间</th>",
"<th>备注</th>",
"<th>设置</th>",
"共 1 条咨询",
`name="phone" value="13900005555"`,
`<select id="status" name="status">`,
`<option value="" selected>全部状态</option>`,
`name="time_from" value="2000-01-01" type="date"`,
`name="time_to" value="2999-12-31" type="date"`,
`class="status-pill status-pending">待派单</span>`,
`class="button secondary small">设置</summary>`,
`href="/admin/price-inquiries">全部</a>`,
} {
if !strings.Contains(body, expected) {
t.Fatalf("admin price inquiry page missing %q: %s", expected, body)
}
}
if strings.Contains(body, "<h1>价格咨询派单</h1>") || strings.Contains(body, "咨询单 ID") {
t.Fatalf("admin price inquiry page rendered removed title or ID column: %s", body)
}
for _, removed := range []string{`name="created_from"`, `name="created_to"`, `name="updated_from"`, `name="updated_to"`} {
if strings.Contains(body, removed) {
t.Fatalf("admin price inquiry page rendered removed time filter %q: %s", removed, body)
}
}
if strings.Count(body, "<strong>13900005555</strong>") != 1 {
t.Fatalf("admin price inquiry page rendered duplicate rows: %s", body)
}
settings := url.Values{
"id": {createdInquiry.ID},
"area": {"浦东"},
"phone": {"13900005555"},
"status_filter": {"pending"},
"time_from": {"2000-01-01"},
"time_to": {"2999-12-31"},
"status": {"assigned"},
"remark": {"已派给浦东门诊"},
}
resp = doRequest(t, app, http.MethodPost, "/admin/price-inquiries/settings", settings.Encode(), map[string]string{
"Cookie": cookie,
"Content-Type": "application/x-www-form-urlencoded",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("save price inquiry settings status = %d, want %d: %s", resp.StatusCode, http.StatusSeeOther, readBody(t, resp))
}
if location := resp.Header.Get("Location"); !strings.Contains(location, "area=%E6%B5%A6%E4%B8%9C") || !strings.Contains(location, "phone=13900005555") || !strings.Contains(location, "status=pending") || !strings.Contains(location, "time_from=2000-01-01") {
t.Fatalf("save price inquiry settings Location = %q, want preserved filters", location)
}
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?area=浦东&phone=13900005555&status=assigned&time_from=2000-01-01&time_to=2999-12-31", "", map[string]string{
"Cookie": cookie,
"Accept": "text/html",
})
defer resp.Body.Close()
body = readBody(t, resp)
if resp.StatusCode != http.StatusOK || !strings.Contains(body, `class="status-pill status-assigned">已经派单</span>`) || !strings.Contains(body, "已派给浦东门诊") || !strings.Contains(body, `<option value="assigned" selected>已经派单</option>`) {
t.Fatalf("saved price inquiry settings not rendered, status/body = %d/%s", resp.StatusCode, body)
}
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?status=completed", "", map[string]string{
"Cookie": cookie,
"Accept": "text/html",
})
defer resp.Body.Close()
body = readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin status-filtered price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
}
if strings.Contains(body, "13900005555") || !strings.Contains(body, "共 0 条咨询") {
t.Fatalf("admin status filter did not remove non-matching inquiries: %s", body)
}
settings.Set("status", "completed")
settings.Set("remark", "用户已联系,等待到院")
resp = doRequest(t, app, http.MethodPost, "/admin/price-inquiries/settings", settings.Encode(), map[string]string{
"Cookie": cookie,
"Content-Type": "application/x-www-form-urlencoded",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("overwrite price inquiry settings status = %d, want %d: %s", resp.StatusCode, http.StatusSeeOther, readBody(t, resp))
}
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?area=浦东", "", map[string]string{
"Cookie": cookie,
"Accept": "text/html",
})
defer resp.Body.Close()
body = readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("updated admin price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
}
for _, expected := range []string{"已跟进", "用户已联系,等待到院"} {
if !strings.Contains(body, expected) {
t.Fatalf("updated admin price inquiry page missing %q: %s", expected, body)
}
}
if strings.Contains(body, "已派给浦东门诊") {
t.Fatalf("old price inquiry remark was not overwritten: %s", body)
}
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?phone=13999999999", "", map[string]string{
"Cookie": cookie,
"Accept": "text/html",
})
defer resp.Body.Close()
body = readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin phone-filtered price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
}
if strings.Contains(body, "13900005555") || !strings.Contains(body, "共 0 条咨询") {
t.Fatalf("admin phone filter did not remove non-matching inquiries: %s", body)
}
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?time_from=2999-01-01", "", map[string]string{
"Cookie": cookie,
"Accept": "text/html",
})
defer resp.Body.Close()
body = readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin time-filtered price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
}
if strings.Contains(body, "13900005555") || !strings.Contains(body, "共 0 条咨询") {
t.Fatalf("admin time filters did not remove out-of-range inquiries: %s", body)
}
}
func TestAdminPriceInquiryPagination(t *testing.T) {
app, closeDB := newTestAdminApp(t)
defer closeDB()
var firstInquiryID string
for i := 0; i < 21; i++ {
phone := fmt.Sprintf("139100100%02d", i)
id := createTestPriceInquiry(t, app, fmt.Sprintf("price_inquiry_page_%02d", i), phone)
if i == 0 {
firstInquiryID = id
}
}
cookie := loginAdmin(t, app)
resp := doRequest(t, app, http.MethodGet, "/admin/price-inquiries?page_size=20", "", map[string]string{
"Cookie": cookie,
"Accept": "text/html",
})
defer resp.Body.Close()
body := readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("first page status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
}
for _, expected := range []string{
"共 21 条咨询,第 1 / 2 页",
`<option value="20" selected>20 条/页</option>`,
`<input type="hidden" name="page" value="1">`,
`<input type="hidden" name="page_size" value="20">`,
`href="/admin/price-inquiries?page=2&amp;page_size=20">下一页</a>`,
} {
if !strings.Contains(body, expected) {
t.Fatalf("first page missing pagination element %q: %s", expected, body)
}
}
if strings.Count(body, `class="button secondary small">设置</summary>`) != 20 {
t.Fatalf("first page rendered wrong row count: %s", body)
}
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?page=2&page_size=20", "", map[string]string{
"Cookie": cookie,
"Accept": "text/html",
})
defer resp.Body.Close()
body = readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("second page status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
}
for _, expected := range []string{
"共 21 条咨询,第 2 / 2 页",
`href="/admin/price-inquiries?page_size=20">上一页</a>`,
`<span class="button secondary small disabled">下一页</span>`,
} {
if !strings.Contains(body, expected) {
t.Fatalf("second page missing pagination element %q: %s", expected, body)
}
}
if strings.Count(body, `class="button secondary small">设置</summary>`) != 1 {
t.Fatalf("second page rendered wrong row count: %s", body)
}
settings := url.Values{
"id": {firstInquiryID},
"status": {"assigned"},
"remark": {"分页保留备注"},
"page": {"2"},
"page_size": {"20"},
}
resp = doRequest(t, app, http.MethodPost, "/admin/price-inquiries/settings", settings.Encode(), map[string]string{
"Cookie": cookie,
"Content-Type": "application/x-www-form-urlencoded",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("save paged settings status = %d, want %d: %s", resp.StatusCode, http.StatusSeeOther, readBody(t, resp))
}
location := resp.Header.Get("Location")
if !strings.Contains(location, "page=2") || !strings.Contains(location, "page_size=20") || !strings.Contains(location, "message=") {
t.Fatalf("save paged settings Location = %q, want preserved pagination", location)
}
}
func TestAdminPriceInquiryRejectsInvalidPagination(t *testing.T) {
app, closeDB := newTestAdminApp(t)
defer closeDB()
cookie := loginAdmin(t, app)
for _, target := range []string{
"/admin/price-inquiries?page=0",
"/admin/price-inquiries?page=abc",
"/admin/price-inquiries?page_size=10",
"/admin/price-inquiries?page_size=abc",
} {
resp := doRequest(t, app, http.MethodGet, target, "", map[string]string{
"Cookie": cookie,
"Accept": "text/html",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("%s status = %d, want %d: %s", target, resp.StatusCode, http.StatusBadRequest, readBody(t, resp))
}
}
}
func TestWechatPhoneCodeDoesNotBindMockPhone(t *testing.T) {
app, closeDB := newTestAdminAppWithConfig(t, config.Config{WeChatAPIBase: "https://unused.invalid"})
defer closeDB()
session := struct{ User struct{ Openid string } }{}
session.User.Openid = "test-user"
var resp *http.Response
resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"`+session.User.Openid+`","phoneCode":"test-phone-code"}`, map[string]string{
"Content-Type": "application/json",
})
defer resp.Body.Close()
body := readBody(t, resp)
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("phone code bind status = %d, want %d: %s", resp.StatusCode, http.StatusInternalServerError, body)
}
if !strings.Contains(body, "GUAHAO_WECHAT_APPID") || !strings.Contains(body, "GUAHAO_WECHAT_SECRET") {
t.Fatalf("phone code bind error = %s, want missing WeChat configuration error", body)
}
resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"`+session.User.Openid+`","phone":"13900002222"}`, map[string]string{
"Content-Type": "application/json",
})
defer resp.Body.Close()
body = readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("explicit phone bind status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
}
if strings.Contains(body, "13800005678") || !strings.Contains(body, "13900002222") {
t.Fatalf("explicit phone bind body = %s, want real provided phone only", body)
}
}
func TestWechatPhoneBindCreatesMissingDemoUser(t *testing.T) {
app, closeDB := newTestAdminApp(t)
defer closeDB()
resp := doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"demo_openid_demo","phone":"13900004444"}`, map[string]string{
"Content-Type": "application/json",
})
defer resp.Body.Close()
body := readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("phone bind status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
}
if !strings.Contains(body, `"openid":"demo_openid_demo"`) || !strings.Contains(body, "13900004444") {
t.Fatalf("phone bind body = %s, want created demo user with bound phone", body)
}
}
func TestWechatPhoneCodeExchangesAndBindsRealPhone(t *testing.T) {
var tokenCalls atomic.Int64
var phoneCalls atomic.Int64
wechat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/sns/jscode2session":
w.Write([]byte(`{"openid":"phone-exchange-user"}`))
case "/cgi-bin/token":
tokenCalls.Add(1)
if r.URL.Query().Get("grant_type") != "client_credential" {
t.Fatalf("grant_type = %q, want client_credential", r.URL.Query().Get("grant_type"))
}
if r.URL.Query().Get("appid") != "test-appid" || r.URL.Query().Get("secret") != "test-secret" {
t.Fatalf("unexpected appid/secret query: %s", r.URL.RawQuery)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"test-token","expires_in":7200}`))
case "/wxa/business/getuserphonenumber":
phoneCalls.Add(1)
if r.URL.Query().Get("access_token") != "test-token" {
t.Fatalf("access_token = %q, want test-token", r.URL.Query().Get("access_token"))
}
var body struct {
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode phone request body: %v", err)
}
if body.Code != "real-phone-code" {
t.Fatalf("phone code = %q, want real-phone-code", body.Code)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"errcode":0,"errmsg":"ok","phone_info":{"phoneNumber":"13900003333","purePhoneNumber":"13900003333","countryCode":"86"}}`))
default:
http.NotFound(w, r)
}
}))
defer wechat.Close()
app, closeDB := newTestAdminAppWithConfig(t, config.Config{
WeChatAppID: "test-appid",
WeChatAppSecret: "test-secret",
WeChatAPIBase: wechat.URL,
})
defer closeDB()
resp := doRequest(t, app, http.MethodPost, "/api/auth/wechat/session", `{"code":"phone_exchange"}`, map[string]string{
"Content-Type": "application/json",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("session status = %d, want %d: %s", resp.StatusCode, http.StatusOK, readBody(t, resp))
}
var session struct {
User struct {
Openid string `json:"openid"`
} `json:"user"`
}
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
t.Fatalf("decode session: %v", err)
}
resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"`+session.User.Openid+`","phoneCode":"real-phone-code"}`, map[string]string{
"Content-Type": "application/json",
})
defer resp.Body.Close()
body := readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("phone bind status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
}
if !strings.Contains(body, "13900003333") || strings.Contains(body, "13800005678") {
t.Fatalf("phone bind body = %s, want exchanged real phone only", body)
}
if tokenCalls.Load() != 1 || phoneCalls.Load() != 1 {
t.Fatalf("tokenCalls/phoneCalls = %d/%d, want 1/1", tokenCalls.Load(), phoneCalls.Load())
}
}
func newTestAdminApp(t *testing.T) (*fiber.App, func()) {
return newTestAdminAppWithConfig(t, config.Config{})
}
func newTestAdminAppWithConfig(t *testing.T, cfgOverride config.Config) (*fiber.App, func()) {
t.Helper()
database, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
ctx := context.Background()
schemaPath := filepath.Join("..", "..", "migrations", "schema.sql")
if err := service.ApplySchema(ctx, database, schemaPath); err != nil {
database.Close()
t.Fatalf("apply schema: %v", err)
}
log := logrus.New()
log.SetOutput(io.Discard)
svc := service.New(database, log)
cfg := cfgOverride
cfg.AllowOrigins = "*"
if cfg.WeChatAPIBase == "" {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/sns/jscode2session" {
json.NewEncoder(w).Encode(map[string]string{"openid": "test-openid-" + r.URL.Query().Get("js_code")})
return
}
http.NotFound(w, r)
}))
t.Cleanup(server.Close)
cfg.WeChatAPIBase = server.URL
cfg.WeChatAppID = "test-appid"
cfg.WeChatAppSecret = "test-secret"
}
if cfg.AdminUsername == "" {
cfg.AdminUsername = "test-admin"
}
if cfg.AdminPassword == "" {
cfg.AdminPassword = "test-password"
}
if cfg.AdminSessionSecret == "" {
cfg.AdminSessionSecret = testAdminSessionSecret
}
return New(cfg, svc, log), func() { database.Close() }
}
func createTestPriceInquiry(t *testing.T, app *fiber.App, code, phone string) string {
t.Helper()
resp := doRequest(t, app, http.MethodPost, "/api/auth/wechat/session", fmt.Sprintf(`{"code":%q}`, code), map[string]string{
"Content-Type": "application/json",
})
var session struct {
User struct {
Openid string `json:"openid"`
} `json:"user"`
}
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
resp.Body.Close()
t.Fatalf("decode session: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("session status = %d, want %d", resp.StatusCode, http.StatusOK)
}
resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", fmt.Sprintf(`{"openid":%q,"phone":%q}`, session.User.Openid, phone), map[string]string{
"Content-Type": "application/json",
})
body := readBody(t, resp)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("phone bind status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
}
resp = doRequest(t, app, http.MethodPost, "/api/price-inquiries", fmt.Sprintf(`{"openid":%q,"province":"上海市","city":"上海市","district":"浦东新区","project_name":"半月板损伤"}`, session.User.Openid), map[string]string{
"Content-Type": "application/json",
})
body = readBody(t, resp)
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusCreated, body)
}
var created struct {
ID string `json:"id"`
}
if err := json.Unmarshal([]byte(body), &created); err != nil {
t.Fatalf("decode price inquiry: %v", err)
}
if created.ID == "" {
t.Fatalf("price inquiry missing id: %s", body)
}
return created.ID
}
func loginAdmin(t *testing.T, app *fiber.App) string {
t.Helper()
return adminSessionCookieHeader(config.Config{AdminSessionSecret: testAdminSessionSecret}, adminUser{
Subject: "test-admin",
DisplayName: "test-admin",
})
}
func adminSessionCookieHeader(cfg config.Config, user adminUser) string {
ui := newAdminUI(cfg, nil)
token := ui.signSession(user, time.Now().Add(8*time.Hour))
return adminSessionCookie + "=" + token
}
func doRequest(t *testing.T, app *fiber.App, method, target, body string, headers map[string]string) *http.Response {
t.Helper()
req := httptest.NewRequest(method, target, strings.NewReader(body))
return testRequest(t, app, req, headers)
}
func testRequest(t *testing.T, app *fiber.App, req *http.Request, headers map[string]string) *http.Response {
t.Helper()
for key, value := range headers {
req.Header.Set(key, value)
}
resp, err := app.Test(req)
if err != nil {
t.Fatalf("%s %s: %v", req.Method, req.URL.String(), err)
}
return resp
}
func readBody(t *testing.T, resp *http.Response) string {
t.Helper()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
return string(body)
}