Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ jobs:
CREATE DATABASE home_server_analytics_test_ci;
CREATE DATABASE home_server_readcache_test_ci;
CREATE DATABASE home_server_manuals_test_ci CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE home_server_file_sharing_test_ci CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'home_server_ci'@'%' IDENTIFIED BY 'ci-isolated-tests';
GRANT ALL ON home_server_accounts_http_test_ci.* TO 'home_server_ci'@'%';
GRANT ALL ON home_server_accounts_migration_test_ci.* TO 'home_server_ci'@'%';
Expand All @@ -51,13 +52,16 @@ jobs:
GRANT ALL ON home_server_analytics_test_ci.* TO 'home_server_ci'@'%';
GRANT ALL ON home_server_readcache_test_ci.* TO 'home_server_ci'@'%';
GRANT ALL ON home_server_manuals_test_ci.* TO 'home_server_ci'@'%';
GRANT ALL ON home_server_file_sharing_test_ci.* TO 'home_server_ci'@'%';
SQL

- name: Exercise availability-first Redis policy
run: docker exec ${{ job.services.redis.id }} redis-cli CONFIG SET maxmemory-policy allkeys-lru

- name: Check public deployment examples
run: python3 script/check_public_deployment.py
run: |
python3 -m unittest script.test_check_public_deployment
python3 script/check_public_deployment.py

- name: Build
run: go build -v ./...
Expand All @@ -75,14 +79,16 @@ jobs:
HOME_SERVER_ANALYTICS_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_analytics_test_ci?parseTime=true'
HOME_SERVER_READ_CACHE_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_readcache_test_ci?parseTime=true'
MANUALS_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_manuals_test_ci?parseTime=true'
run: go test -v ./config ./api/accounts ./api/webprojects ./api/manuals ./api/applications ./api/auth ./api/middleware ./api/passport ./api/webdav ./app/... ./domain/model ./domain/repository/... ./domain/service/accounts ./domain/service/manuals ./domain/service/webprojects ./domain/service/webcomments ./domain/service/applications ./domain/service/passport ./domain/service/rsa ./internal/accountsmigrate ./cmd/accounts-migrate ./cmd/accounts-maintain ./utils ./utils/md ./utils/log ./utils/ginfmt ./utils/cache ./domain/service/webanalytics
FILE_SHARING_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_file_sharing_test_ci?parseTime=true'
run: go test -v ./config ./api/accounts ./api/webprojects ./api/manuals ./api/fileshare ./api/applications ./api/auth ./api/middleware ./api/passport ./api/webdav ./app/... ./domain/model ./domain/repository/... ./domain/service/accounts ./domain/service/manuals ./domain/service/fileshare ./domain/service/resourcepasswords ./domain/service/webprojects ./domain/service/webcomments ./domain/service/applications ./domain/service/passport ./domain/service/rsa ./internal/accountsmigrate ./cmd/accounts-migrate ./cmd/accounts-maintain ./utils ./utils/md ./utils/log ./utils/ginfmt ./utils/cache ./domain/service/webanalytics

- name: Test isolated DAL cache bindings
env:
MANUALS_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_manuals_test_ci?parseTime=true'
HOME_SERVER_TEST_REDIS_ADDR: '127.0.0.1:16389'
HOME_SERVER_READ_CACHE_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_readcache_test_ci?parseTime=true'
run: go test -v ./domain/dal -run 'Test(ReadCache|Manual)' -count=1
FILE_SHARING_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_file_sharing_test_ci?parseTime=true'
run: go test -v ./domain/dal -run 'Test(ReadCache|Manual|FileShare)' -count=1

- name: Test AI API client
run: python3 script/test_home_server_api.py -v
Expand Down
2 changes: 1 addition & 1 deletion api/accounts/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ func publicBootstrap(c *gin.Context) {
return
}
runtime := config.Runtime()
respond(c, map[string]interface{}{"site": map[string]string{"title": runtime.SiteTitle, "notice": runtime.SiteNotice}, "registration": value, "modules": map[string]bool{"manuals": runtime.Manuals.Enabled}}, nil)
respond(c, map[string]interface{}{"site": map[string]string{"title": runtime.SiteTitle, "notice": runtime.SiteNotice}, "registration": value, "modules": map[string]bool{"manuals": runtime.Manuals.Enabled, "file_sharing": runtime.FileSharing.Enabled}}, nil)
}

// validateInvitation 处理 POST /api/auth/invitations/validate:在来源限流后校验邀请码,仅返回有效状态与到期时间,不消费邀请码。
Expand Down
2 changes: 1 addition & 1 deletion api/accounts/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ func newHTTPFixture(t *testing.T, initialApplications ...bool) *httpFixture {
t.Fatal(err)
}
baseline := []string{strings.Replace(string(base), "use home_server;", "", 1)}
for _, name := range []string{"20260909_web_projects.sql", "20260912_applications.sql", "20260916_web_comments.sql"} {
for _, name := range []string{"20260909_web_projects.sql", "20260912_applications.sql", "20260916_web_comments.sql", "20260919_resource_passwords.sql"} {
raw, err := migrations.SQL.ReadFile(name)
if err != nil {
t.Fatal(err)
Expand Down
125 changes: 125 additions & 0 deletions api/accounts/resource_password_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package accounts_test

import (
"net/http"
"strconv"
"strings"
"testing"

"github.com/mcoder2014/home_server/domain/model"
)

func TestHTTPWebProjectPasswordProtectsContentAndComments(t *testing.T) {
f := newHTTPFixture(t)
owner := f.login(f.owner, integrationPassword)
guest := f.login(f.guest, integrationPassword)
project, release := f.privatePage(owner)
id := project["id"].(string)
base := "/api/web-share/" + id
project = requireSuccess(t, f.request(http.MethodPatch, base, owner, map[string]interface{}{"access_mode": "public", "container_mode": "raw"}, map[string]string{"If-Match": strconv.FormatInt(number(project["revision"]), 10)}))

status := requireSuccess(t, f.request(http.MethodGet, base+"/password", owner, nil, nil))
if status["password_protected"] != false || number(status["version"]) != 0 {
t.Fatalf("unexpected initial password state: %v", status)
}
if response := f.request(http.MethodPut, base+"/password", owner, map[string]interface{}{"password": "short", "version": 0}, nil); response.Status != http.StatusBadRequest {
t.Fatalf("short password accepted: status=%d code=%d", response.Status, response.Code)
}
status = requireSuccess(t, f.request(http.MethodPut, "/api/web-projects/"+id+"/password", owner, map[string]interface{}{"password": "correct horse battery staple", "version": 0}, nil))
if status["password_protected"] != true || number(status["version"]) != 1 {
t.Fatalf("password was not enabled: %v", status)
}
if response := f.request(http.MethodPut, base+"/password", owner, map[string]interface{}{"password": "another valid password", "version": 0}, nil); response.Status != http.StatusConflict || response.Code != 5 {
t.Fatalf("stale password version accepted: status=%d code=%d", response.Status, response.Code)
}

pagePath := "/p/http-private-fixture/index.html"
locked := f.request(http.MethodGet, pagePath, nil, nil, map[string]string{"Accept": "text/html"})
if locked.Status != http.StatusForbidden || locked.Header.Get("X-Resource-Password") != "required" || locked.Header.Get("X-Resource-ID") != id || !strings.Contains(string(locked.Raw), "type=\"password\"") {
t.Fatalf("direct HTML did not return the native password gate: status=%d headers=%v body=%s", locked.Status, locked.Header, locked.Raw)
}
if ownerPage := f.request(http.MethodGet, pagePath, owner, nil, nil); ownerPage.Status != http.StatusOK {
t.Fatalf("owner did not bypass password after ACL checks: %d", ownerPage.Status)
}
for _, projectStatus := range []model.WebProjectStatus{model.WebProjectStatusDisabled, model.WebProjectStatusDeleted} {
if _, err := f.database.Exec("UPDATE web_project SET status = ? WHERE id = ?", projectStatus, id); err != nil {
t.Fatal(err)
}
if ownerPage := f.request(http.MethodGet, pagePath, owner, nil, nil); ownerPage.Status != http.StatusNotFound {
t.Fatalf("owner password bypass ignored %s project state: %d", projectStatus.String(), ownerPage.Status)
}
if ownerComments := f.request(http.MethodGet, base+"/comment-threads", owner, nil, nil); ownerComments.Status != http.StatusOK {
t.Fatalf("owner lost %s project comment history: %d", projectStatus.String(), ownerComments.Status)
}
if guestComments := f.request(http.MethodGet, base+"/comment-threads", guest, nil, nil); guestComments.Status != http.StatusNotFound {
t.Fatalf("non-owner read %s project comments: %d", projectStatus.String(), guestComments.Status)
}
}
if _, err := f.database.Exec("UPDATE web_project SET status = ?, moderation_status = 'blocked' WHERE id = ?", model.WebProjectStatusEnabled, id); err != nil {
t.Fatal(err)
}
if ownerPage := f.request(http.MethodGet, pagePath, owner, nil, nil); ownerPage.Status != http.StatusNotFound {
t.Fatalf("owner password bypass ignored moderation state: %d", ownerPage.Status)
}
if _, err := f.database.Exec("UPDATE web_project SET moderation_status = 'normal' WHERE id = ?", id); err != nil {
t.Fatal(err)
}
for _, request := range []struct{ method, path string }{{http.MethodHead, pagePath}, {http.MethodGet, "/p/http-private-fixture/style.css"}, {http.MethodGet, base + "/view-context"}} {
response := f.request(request.method, request.path, nil, nil, nil)
if response.Status != http.StatusForbidden {
t.Fatalf("password gate bypassed for %s %s: status=%d code=%d", request.method, request.path, response.Status, response.Code)
}
}

wrong := f.request(http.MethodPost, base+"/unlock", nil, map[string]interface{}{"password": "wrong password"}, nil)
if wrong.Status != http.StatusForbidden || wrong.Code != 3 {
t.Fatalf("wrong password must be stable 403/code=3: status=%d code=%d", wrong.Status, wrong.Code)
}
unlocked := f.request(http.MethodPost, "/api/web-projects/"+id+"/unlock", nil, map[string]interface{}{"password": "correct horse battery staple"}, nil)
grant := passwordGrantCookie(t, unlocked.Cookies)
if unlocked.Status != http.StatusOK || !grant.Secure || !grant.HttpOnly || grant.Path != "/" || grant.SameSite != http.SameSiteLaxMode {
t.Fatalf("unsafe unlock response: status=%d cookie=%+v", unlocked.Status, grant)
}
cookieHeader := grant.Name + "=" + grant.Value
if response := f.request(http.MethodGet, "/p/http-private-fixture/style.css", nil, nil, map[string]string{"Cookie": cookieHeader}); response.Status != http.StatusOK {
t.Fatalf("valid grant did not unlock an asset: %d", response.Status)
}
if response := f.request(http.MethodGet, base+"/view-context", nil, nil, map[string]string{"Cookie": cookieHeader}); response.Status != http.StatusOK {
t.Fatalf("valid grant did not unlock view context: %d", response.Status)
}
project = requireSuccess(t, f.request(http.MethodPatch, base, owner, map[string]interface{}{"container_mode": "enhanced"}, map[string]string{"If-Match": strconv.FormatInt(number(project["revision"]), 10)}))
comment := map[string]interface{}{"request_id": "password-comment", "release_id": release, "page_key": "path:index.html", "page_path": "index.html", "anchor": map[string]interface{}{"kind": "page"}, "body": "protected feedback"}
guestCookies := guest.Cookie.Name + "=" + guest.Cookie.Value + "; " + cookieHeader
if response := f.request(http.MethodPost, base+"/comment-threads", guest, comment, map[string]string{"Cookie": guestCookies}); response.Status != http.StatusCreated {
t.Fatalf("unlocked comment write failed: status=%d body=%s", response.Status, response.Raw)
}
if response := f.request(http.MethodGet, base+"/comment-threads", guest, nil, nil); response.Status != http.StatusForbidden || response.Code != 3 {
t.Fatalf("comment read bypassed password: status=%d code=%d", response.Status, response.Code)
}

status = requireSuccess(t, f.request(http.MethodPut, base+"/password", owner, map[string]interface{}{"password": "replacement password", "version": 1}, nil))
if number(status["version"]) != 2 {
t.Fatalf("password version did not advance: %v", status)
}
if response := f.request(http.MethodGet, pagePath, nil, nil, map[string]string{"Cookie": cookieHeader}); response.Status != http.StatusForbidden {
t.Fatalf("old cookie survived password change: %d", response.Status)
}
status = requireSuccess(t, f.request(http.MethodPut, base+"/password", owner, map[string]interface{}{"password": "", "version": 2}, nil))
if status["password_protected"] != false || number(status["version"]) != 3 {
t.Fatalf("clear did not retain and advance version: %v", status)
}
if response := f.request(http.MethodGet, pagePath, nil, nil, nil); response.Status != http.StatusOK {
t.Fatalf("cleared password still blocked content: %d", response.Status)
}
}

func passwordGrantCookie(t *testing.T, cookies []*http.Cookie) *http.Cookie {
t.Helper()
for _, cookie := range cookies {
if strings.HasPrefix(cookie.Name, "__Host-cq_read_") && cookie.MaxAge > 0 {
return cookie
}
}
t.Fatal("unlock did not issue a resource grant cookie")
return nil
}
Loading
Loading