diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 3f52fedc..0c700ba1 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -69,6 +69,8 @@ jobs: go test -v -coverprofile=coverage.out -covermode=atomic ./store/... ;; server) + # Includes ./server/test, which boots the real server via + # server.NewServer and smokes every router it mounts. go test -v -race -coverprofile=coverage.out -covermode=atomic ./server/... ;; internal) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d0c396a..42c1c44e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,10 @@ env: ARTIFACT_PREFIX: memos jobs: + upgrade-smoke: + name: Verify fresh install and stable upgrade + uses: ./.github/workflows/upgrade-smoke.yml + prepare: name: Extract Version runs-on: ubuntu-latest @@ -63,7 +67,7 @@ jobs: build-frontend: name: Build Frontend - needs: prepare + needs: [prepare, upgrade-smoke] runs-on: ubuntu-latest steps: - name: Checkout code diff --git a/.github/workflows/upgrade-smoke.yml b/.github/workflows/upgrade-smoke.yml new file mode 100644 index 00000000..28301cbe --- /dev/null +++ b/.github/workflows/upgrade-smoke.yml @@ -0,0 +1,107 @@ +name: Upgrade Smoke + +# Verifies that a Memos instance carrying real data upgrades cleanly from the +# previous stable release to the current build, and that a fresh install still +# starts. These tiers need Docker and take minutes, so they run for relevant +# pull requests and before releases rather than on every backend change. +# +# Fast startup coverage (server.NewServer plus route smoke, SQLite only) lives in +# the `server` group of backend-tests.yml and runs on every pull request. + +on: + workflow_call: + workflow_dispatch: + pull_request: + branches: [main] + paths: + # Anything that can change how an existing database is opened or upgraded. + - "store/migration/**" + - "store/migrator.go" + - "store/db/**" + - "store/test/containers.go" + - "store/test/migrator*_test.go" + - "store/test/store.go" + - "server/server.go" + - "scripts/Dockerfile" + - "scripts/entrypoint.sh" + - "scripts/release_smoke_test.sh" + - ".github/workflows/upgrade-smoke.yml" + +concurrency: + # Keep this distinct from a caller's concurrency group when the workflow is + # invoked from release.yml; sharing a group with cancel-in-progress would + # cancel the parent release workflow. + group: ${{ github.workflow }}-upgrade-smoke-${{ github.ref }} + cancel-in-progress: true + +env: + GO_VERSION: "1.26.2" + NODE_VERSION: "24" + PNPM_VERSION: "11.0.1" + +jobs: + migration-upgrade: + name: Upgrade from previous stable (${{ matrix.driver }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + driver: [sqlite, mysql, postgres] + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + cache-dependency-path: go.sum + + # No -race here: testcontainers has known races in its reaper, which is why + # store/test/migrator_test.go documents skipping the race detector. + - name: Run migration and upgrade tests + run: | + go test -v -timeout 30m -run 'TestMigration|TestUpgrade|TestFreshInstall' ./store/test/... + env: + DRIVER: ${{ matrix.driver }} + + release-smoke: + name: Fresh install and upgrade through the release image + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + # release_smoke_test.sh resolves the previous stable release from Git + # tags, so it needs full history and tags. + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4.2.0 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + + - name: Install frontend dependencies + working-directory: web + run: pnpm install --frozen-lockfile + + - name: Run release smoke test + run: ./scripts/release_smoke_test.sh + + entrypoint: + name: Entrypoint secret handling + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Run entrypoint tests + run: ./scripts/entrypoint_test.sh diff --git a/server/router/api/v1/gateway_route_resolver_test.go b/server/router/api/v1/gateway_route_resolver_test.go index 65236229..2eed8cda 100644 --- a/server/router/api/v1/gateway_route_resolver_test.go +++ b/server/router/api/v1/gateway_route_resolver_test.go @@ -274,6 +274,7 @@ func splitGatewayPathSegments(path string) []string { segments = append(segments, trimmed[start:index]) start = index + 1 } + default: } } return append(segments, trimmed[start:]) @@ -291,6 +292,7 @@ func splitGatewayTemplateVerb(template string) (string, string) { if depth == 0 { return template[:index], template[index+1:] } + default: } } return template, "" diff --git a/server/test/startup_test.go b/server/test/startup_test.go new file mode 100644 index 00000000..9a40ac05 --- /dev/null +++ b/server/test/startup_test.go @@ -0,0 +1,437 @@ +// Package test contains black-box startup smoke tests for the full server. +// +// Every other server test constructs apiv1.APIV1Service directly, which skips +// server.NewServer entirely. That leaves route registration, gRPC-gateway +// wiring, MCP/RSS/fileserver/frontend mounting, CORS and the secret bootstrap +// covered only by the Docker release script. These tests boot the real server +// the same way cmd/memos/main.go does so a wiring regression fails in CI. +package test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + // sqlite driver. + _ "modernc.org/sqlite" + + "github.com/usememos/memos/internal/profile" + "github.com/usememos/memos/internal/version" + "github.com/usememos/memos/server" + "github.com/usememos/memos/store" + "github.com/usememos/memos/store/db" +) + +const ( + testAdminUsername = "startup-admin" + testAdminPassword = "startup-password" +) + +// instanceOptions configures a single server boot. +type instanceOptions struct { + // demo enables demo mode, which seeds the database during migration. + demo bool + // instanceURL is the public instance URL. An empty value makes the + // instance private, which restricts anonymous access to bootstrap methods. + instanceURL string + // dataDir reuses an existing data directory instead of a fresh one, which + // is how a restart against an already-migrated database is simulated. + dataDir string +} + +// instance is a booted server plus the HTTP plumbing needed to talk to it. +type instance struct { + server *server.Server + profile *profile.Profile + baseURL string + client *http.Client +} + +// bootInstance starts a full server following the same sequence as +// cmd/memos/main.go: validate profile, open the driver, migrate, load +// deployment configuration, construct the server, then listen. +// +// Keep this in lockstep with main.go. The value of these tests comes from +// exercising the real startup path rather than a hand-assembled subset of it. +func bootInstance(ctx context.Context, t *testing.T, opts instanceOptions) *instance { + t.Helper() + + dataDir := opts.dataDir + if dataDir == "" { + dataDir = t.TempDir() + } + + instanceProfile := &profile.Profile{ + Demo: opts.demo, + Addr: "127.0.0.1", + Port: unusedPort(t), + Data: dataDir, + Driver: "sqlite", + InstanceURL: opts.instanceURL, + Version: version.GetCurrentVersion(), + Commit: version.Commit, + } + require.NoError(t, instanceProfile.Validate(), "profile should validate") + + dbDriver, err := db.NewDBDriver(instanceProfile) + require.NoError(t, err, "should open database driver") + + storeInstance := store.New(dbDriver, instanceProfile) + require.NoError(t, storeInstance.Migrate(ctx), "should migrate database") + + // main.go calls LoadDeploymentConfiguration, which scans the fixed + // /etc/secrets path. Point the scan at a per-test directory so the result + // cannot depend on host state; the missing-directory branch is identical. + require.NoError(t, storeInstance.LoadDeploymentConfigurationDir(ctx, filepath.Join(dataDir, "secrets")), + "should load deployment configuration") + + s, err := server.NewServer(ctx, instanceProfile, storeInstance) + require.NoError(t, err, "should construct server") + require.NoError(t, s.Start(ctx), "should start server") + + inst := &instance{ + server: s, + profile: instanceProfile, + baseURL: fmt.Sprintf("http://127.0.0.1:%d", instanceProfile.Port), + client: &http.Client{Timeout: 10 * time.Second}, + } + t.Cleanup(func() { + inst.shutdown(context.Background()) + }) + inst.waitUntilReady(t) + return inst +} + +// shutdown stops the server. Server.Shutdown also closes the store, so a +// subsequent boot against the same data directory must build a new driver, +// which is what bootInstance does and what a real restart does. +func (i *instance) shutdown(ctx context.Context) { + if i.server == nil { + return + } + i.server.Shutdown(ctx) + i.server = nil +} + +func (i *instance) waitUntilReady(t *testing.T) { + t.Helper() + require.Eventually(t, func() bool { + resp, err := i.client.Get(i.baseURL + "/healthz") + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK + }, 30*time.Second, 100*time.Millisecond, "server should become ready") +} + +// do issues a request against the instance and returns the status and body. +func (i *instance) do(t *testing.T, method, path, token string, body any) (int, []byte) { + t.Helper() + + var reader *bytes.Reader + if body != nil { + encoded, err := json.Marshal(body) + require.NoError(t, err) + reader = bytes.NewReader(encoded) + } else { + reader = bytes.NewReader(nil) + } + + req, err := http.NewRequestWithContext(context.Background(), method, i.baseURL+path, reader) + require.NoError(t, err) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := i.client.Do(req) + require.NoError(t, err, "%s %s should not fail at the transport level", method, path) + defer resp.Body.Close() + + payload := new(bytes.Buffer) + _, err = payload.ReadFrom(resp.Body) + require.NoError(t, err) + return resp.StatusCode, payload.Bytes() +} + +// createAdmin registers the first user, which the service promotes to admin. +func (i *instance) createAdmin(t *testing.T) { + t.Helper() + + status, body := i.do(t, http.MethodPost, "/api/v1/users", "", map[string]any{ + "username": testAdminUsername, + "password": testAdminPassword, + "email": "startup-admin@example.test", + }) + require.Equal(t, http.StatusOK, status, "creating the first user should succeed: %s", body) + + var created struct { + Username string `json:"username"` + Role string `json:"role"` + } + require.NoError(t, json.Unmarshal(body, &created)) + require.Equal(t, testAdminUsername, created.Username) + require.Equal(t, "ADMIN", created.Role, "the first user should be an admin") +} + +// signIn authenticates as the admin created by createAdmin. +func (i *instance) signIn(t *testing.T) string { + t.Helper() + + status, body := i.do(t, http.MethodPost, "/api/v1/auth/signin", "", map[string]any{ + "passwordCredentials": map[string]any{ + "username": testAdminUsername, + "password": testAdminPassword, + }, + }) + require.Equal(t, http.StatusOK, status, "sign-in should succeed: %s", body) + + var signedIn struct { + AccessToken string `json:"accessToken"` + } + require.NoError(t, json.Unmarshal(body, &signedIn)) + require.NotEmpty(t, signedIn.AccessToken, "sign-in should return an access token") + return signedIn.AccessToken +} + +// createMemo writes a memo with a caller-supplied id so it can be re-read by name. +func (i *instance) createMemo(t *testing.T, token, memoID, content string) { + t.Helper() + + status, body := i.do(t, http.MethodPost, "/api/v1/memos?memoId="+memoID, token, map[string]any{ + "content": content, + "visibility": "PRIVATE", + }) + require.Equal(t, http.StatusOK, status, "creating a memo should succeed: %s", body) + + var created struct { + Name string `json:"name"` + Content string `json:"content"` + } + require.NoError(t, json.Unmarshal(body, &created)) + require.Equal(t, "memos/"+memoID, created.Name) + require.Equal(t, content, created.Content) +} + +// requireMemo asserts a memo is readable and has the expected content. +func (i *instance) requireMemo(t *testing.T, token, memoID, content string) { + t.Helper() + + status, body := i.do(t, http.MethodGet, "/api/v1/memos/"+memoID, token, nil) + require.Equal(t, http.StatusOK, status, "reading memo %s should succeed: %s", memoID, body) + + var fetched struct { + Content string `json:"content"` + } + require.NoError(t, json.Unmarshal(body, &fetched)) + require.Equal(t, content, fetched.Content) +} + +func unusedPort(t *testing.T) int { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + return listener.Addr().(*net.TCPAddr).Port +} + +// TestStartupServesEveryRegisteredRouter boots a fresh instance and checks that +// each router mounted by server.NewServer actually answers. A registration +// order regression or a gateway conflict shows up here as a 404. +func TestStartupServesEveryRegisteredRouter(t *testing.T) { + ctx := context.Background() + inst := bootInstance(ctx, t, instanceOptions{instanceURL: "http://localhost"}) + + t.Run("healthz", func(t *testing.T) { + status, body := inst.do(t, http.MethodGet, "/healthz", "", nil) + require.Equal(t, http.StatusOK, status) + require.Equal(t, "Service ready.", string(body)) + }) + + t.Run("frontend", func(t *testing.T) { + // CI only has the placeholder dist/index.html, so assert the route is + // mounted and serving HTML rather than asserting on built markup. + status, body := inst.do(t, http.MethodGet, "/", "", nil) + require.Equal(t, http.StatusOK, status) + require.Contains(t, strings.ToLower(string(body)), "") + }) + + t.Run("api gateway", func(t *testing.T) { + status, body := inst.do(t, http.MethodGet, "/api/v1/instance/profile", "", nil) + require.Equal(t, http.StatusOK, status, "instance profile should be public: %s", body) + require.Contains(t, string(body), "version") + }) + + t.Run("api gateway form post fallback", func(t *testing.T) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, inst.baseURL+"/api/v1/instance/profile", strings.NewReader("")) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := inst.client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode, "public GET fallback should permit anonymous form posts") + }) + + t.Run("rss", func(t *testing.T) { + status, body := inst.do(t, http.MethodGet, "/explore/rss.xml", "", nil) + require.Equal(t, http.StatusOK, status, "rss route should be mounted: %s", body) + require.Contains(t, string(body), "