test(ci): add upgrade and startup smoke coverage
Exercise real server startup on backend test runs and gate releases on fresh-install, previous-stable upgrade, and entrypoint smoke checks across supported databases.
This commit is contained in:
parent
415a3ec73d
commit
1236d9ba94
9 changed files with 814 additions and 4 deletions
2
.github/workflows/backend-tests.yml
vendored
2
.github/workflows/backend-tests.yml
vendored
|
|
@ -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)
|
||||
|
|
|
|||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
107
.github/workflows/upgrade-smoke.yml
vendored
Normal file
107
.github/workflows/upgrade-smoke.yml
vendored
Normal file
|
|
@ -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
|
||||
|
|
@ -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, ""
|
||||
|
|
|
|||
437
server/test/startup_test.go
Normal file
437
server/test/startup_test.go
Normal file
|
|
@ -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)), "<!doctype html>")
|
||||
})
|
||||
|
||||
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), "<?xml")
|
||||
})
|
||||
|
||||
t.Run("mcp", func(t *testing.T) {
|
||||
// A bare POST is enough to prove the handler is mounted; the MCP
|
||||
// protocol itself is covered by server/router/mcp tests.
|
||||
status, _ := inst.do(t, http.MethodPost, "/mcp", "", map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list",
|
||||
})
|
||||
require.NotEqual(t, http.StatusNotFound, status, "mcp route should be mounted")
|
||||
})
|
||||
}
|
||||
|
||||
// TestStartupFreshInstallRoundTrip covers the new-install path end to end:
|
||||
// register the first user, authenticate, then write and read a memo through
|
||||
// the gRPC gateway.
|
||||
func TestStartupFreshInstallRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
inst := bootInstance(ctx, t, instanceOptions{instanceURL: "http://localhost"})
|
||||
|
||||
inst.createAdmin(t)
|
||||
token := inst.signIn(t)
|
||||
inst.createMemo(t, token, "startup-fresh", "fresh install sentinel")
|
||||
inst.requireMemo(t, token, "startup-fresh", "fresh install sentinel")
|
||||
}
|
||||
|
||||
// TestStartupRestartPreservesData boots, writes data, shuts down, then boots a
|
||||
// second time against the same data directory. This is the path every upgrade
|
||||
// and every container restart takes, and it verifies migration is idempotent
|
||||
// through the real startup sequence rather than through store.Migrate alone.
|
||||
func TestStartupRestartPreservesData(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dataDir := t.TempDir()
|
||||
|
||||
first := bootInstance(ctx, t, instanceOptions{
|
||||
instanceURL: "http://localhost",
|
||||
dataDir: dataDir,
|
||||
})
|
||||
first.createAdmin(t)
|
||||
token := first.signIn(t)
|
||||
first.createMemo(t, token, "startup-restart", "written before restart")
|
||||
firstPort := first.profile.Port
|
||||
first.shutdown(ctx)
|
||||
|
||||
second := bootInstance(ctx, t, instanceOptions{
|
||||
instanceURL: "http://localhost",
|
||||
dataDir: dataDir,
|
||||
})
|
||||
require.NotEqual(t, firstPort, second.profile.Port, "the second boot should bind a new port")
|
||||
|
||||
// Tokens are signed with the instance secret, which must survive a restart.
|
||||
restartToken := second.signIn(t)
|
||||
second.requireMemo(t, restartToken, "startup-restart", "written before restart")
|
||||
second.createMemo(t, restartToken, "startup-after-restart", "written after restart")
|
||||
second.requireMemo(t, restartToken, "startup-after-restart", "written after restart")
|
||||
}
|
||||
|
||||
// TestStartupPrivateInstance verifies an instance with no InstanceURL boots,
|
||||
// still exposes the auth bootstrap surface, and refuses anonymous callers on
|
||||
// non-bootstrap procedures.
|
||||
func TestStartupPrivateInstance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
inst := bootInstance(ctx, t, instanceOptions{instanceURL: ""})
|
||||
|
||||
require.False(t, inst.profile.AllowAnonymous(), "an instance without InstanceURL should be private")
|
||||
|
||||
// Bootstrap methods stay reachable so the sign-in page can render.
|
||||
status, body := inst.do(t, http.MethodGet, "/api/v1/instance/profile", "", nil)
|
||||
require.Equal(t, http.StatusOK, status, "instance profile is an auth bootstrap method: %s", body)
|
||||
|
||||
// Protected procedures are refused for anonymous callers.
|
||||
status, _ = inst.do(t, http.MethodGet, "/api/v1/users", "", nil)
|
||||
require.Equal(t, http.StatusUnauthorized, status, "anonymous ListUsers should be refused")
|
||||
|
||||
// ListMemos is public but not a bootstrap method, so the private-instance
|
||||
// policy must refuse anonymous callers. The Connect transport enforces this.
|
||||
status, _ = inst.do(t, http.MethodPost, "/memos.api.v1.MemoService/ListMemos", "", map[string]any{})
|
||||
require.Equal(t, http.StatusUnauthorized, status,
|
||||
"anonymous ListMemos over Connect should be refused on a private instance")
|
||||
|
||||
// Authenticated access is never affected by private mode.
|
||||
inst.createAdmin(t)
|
||||
token := inst.signIn(t)
|
||||
inst.createMemo(t, token, "startup-private", "private instance sentinel")
|
||||
inst.requireMemo(t, token, "startup-private", "private instance sentinel")
|
||||
}
|
||||
|
||||
// TestStartupPrivateInstanceGatewayPolicy asserts the private-instance policy is
|
||||
// enforced on the gRPC-Gateway transport, not just on Connect.
|
||||
//
|
||||
// This is a regression test for a real gap: the middleware used to read
|
||||
// runtime.RPCMethod(ctx) to decide the procedure, but grpc-gateway wraps
|
||||
// middlewares *around* the generated handler, and it is the generated handler
|
||||
// that annotates the context with the RPC method. runtime.RPCMethod therefore
|
||||
// always reported "not set", the guard skipped Authorizer.CheckAccess entirely,
|
||||
// and anonymous callers could read PUBLIC memos over REST on a private instance.
|
||||
// The gateway now resolves the procedure from the proto HTTP bindings instead.
|
||||
func TestStartupPrivateInstanceGatewayPolicy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
inst := bootInstance(ctx, t, instanceOptions{instanceURL: ""})
|
||||
|
||||
inst.createAdmin(t)
|
||||
token := inst.signIn(t)
|
||||
inst.createMemo(t, token, "startup-private-public", "public on a private instance")
|
||||
status, body := inst.do(t, http.MethodPatch,
|
||||
"/api/v1/memos/startup-private-public?updateMask=visibility", token, map[string]any{
|
||||
"visibility": "PUBLIC",
|
||||
})
|
||||
require.Equal(t, http.StatusOK, status, "should be able to make the memo public: %s", body)
|
||||
|
||||
status, _ = inst.do(t, http.MethodGet, "/api/v1/memos", "", nil)
|
||||
require.Equal(t, http.StatusUnauthorized, status,
|
||||
"anonymous ListMemos over REST should be refused on a private instance")
|
||||
|
||||
status, _ = inst.do(t, http.MethodGet, "/api/v1/memos/startup-private-public", "", nil)
|
||||
require.Equal(t, http.StatusUnauthorized, status,
|
||||
"anonymous GetMemo over REST should be refused on a private instance")
|
||||
|
||||
status, _ = inst.do(t, http.MethodGet, "/explore/rss.xml", "", nil)
|
||||
require.Equal(t, http.StatusNotFound, status,
|
||||
"anonymous RSS should be unavailable on a private instance")
|
||||
}
|
||||
|
||||
// TestStartupDemoMode verifies demo mode boots, which exercises the seed path
|
||||
// in store.Migrate that prod-mode startups never touch.
|
||||
func TestStartupDemoMode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
inst := bootInstance(ctx, t, instanceOptions{
|
||||
demo: true,
|
||||
instanceURL: "http://localhost",
|
||||
})
|
||||
|
||||
status, body := inst.do(t, http.MethodGet, "/api/v1/memos", "", nil)
|
||||
require.Equal(t, http.StatusOK, status, "demo instances serve memos anonymously: %s", body)
|
||||
|
||||
var listed struct {
|
||||
Memos []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"memos"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body, &listed))
|
||||
require.NotEmpty(t, listed.Memos, "demo mode should seed memos")
|
||||
}
|
||||
|
|
@ -31,8 +31,13 @@ const (
|
|||
testPassword = "test"
|
||||
|
||||
// Memos container settings for migration testing.
|
||||
MemosDockerImage = "neosmemo/memos"
|
||||
StableMemosVersion = "stable" // Always points to the latest stable release
|
||||
MemosDockerImage = "neosmemo/memos"
|
||||
// StableMemosVersion is the previous stable release upgrades are tested from.
|
||||
// Pinned rather than tracking the floating "stable" tag so a Docker Hub retag
|
||||
// cannot change what CI verifies. Bump this when a new stable ships.
|
||||
// scripts/release_smoke_test.sh detects the previous release from Git tags
|
||||
// instead, so the black-box tier still follows "stable" automatically.
|
||||
StableMemosVersion = "0.29.1"
|
||||
|
||||
mysqlNetworkAlias = "memos-mysql"
|
||||
postgresNetworkAlias = "memos-postgres"
|
||||
|
|
|
|||
96
store/test/migrator_guardrail_test.go
Normal file
96
store/test/migrator_guardrail_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
// setSchemaVersion overwrites the recorded schema version so upgrade guard rails
|
||||
// can be exercised without fabricating a whole legacy database.
|
||||
func setSchemaVersion(ctx context.Context, t *testing.T, ts *store.Store, schemaVersion string) {
|
||||
t.Helper()
|
||||
|
||||
basicSetting, err := ts.GetInstanceBasicSetting(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
basicSetting.SchemaVersion = schemaVersion
|
||||
_, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
|
||||
Key: storepb.InstanceSettingKey_BASIC,
|
||||
Value: &storepb.InstanceSetting_BasicSetting{BasicSetting: basicSetting},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
stored, err := ts.GetInstanceBasicSetting(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, schemaVersion, stored.SchemaVersion, "schema version should be persisted")
|
||||
}
|
||||
|
||||
// TestMigrationRejectsDowngrade verifies a database written by a newer Memos is
|
||||
// refused rather than silently re-migrated. Starting an old binary against a
|
||||
// newer database is the most common way a rollback corrupts data.
|
||||
func TestMigrationRejectsDowngrade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
ts := NewTestingStore(ctx, t)
|
||||
|
||||
currentVersion, err := ts.GetCurrentSchemaVersion()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Pretend the database was written by a much newer release.
|
||||
setSchemaVersion(ctx, t, ts, "99.0.0")
|
||||
|
||||
err = ts.Migrate(ctx)
|
||||
require.Error(t, err, "migrating a newer database should fail")
|
||||
require.Contains(t, err.Error(), "cannot downgrade schema version",
|
||||
"error should explain the downgrade was refused")
|
||||
require.Contains(t, err.Error(), currentVersion,
|
||||
"error should name the version the binary supports")
|
||||
}
|
||||
|
||||
// TestMigrationRejectsPreV022Installation verifies installations older than the
|
||||
// supported floor are refused with actionable upgrade instructions instead of
|
||||
// failing partway through a migration.
|
||||
func TestMigrationRejectsPreV022Installation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
ts := NewTestingStore(ctx, t)
|
||||
|
||||
// 0.21.x predates moving schema tracking from migration_history to system_setting.
|
||||
setSchemaVersion(ctx, t, ts, "0.21.0")
|
||||
|
||||
err := ts.Migrate(ctx)
|
||||
require.Error(t, err, "migrating a pre-0.22 installation should fail")
|
||||
require.Contains(t, err.Error(), "too old to upgrade directly")
|
||||
require.Contains(t, err.Error(), "0.25.3",
|
||||
"error should name the intermediate version to upgrade through")
|
||||
}
|
||||
|
||||
// TestMigrationAcceptsMinimumSupportedVersion pins the other side of the
|
||||
// supported-version boundary: 0.22.0 must clear the floor check that rejects
|
||||
// 0.21.x.
|
||||
//
|
||||
// This asserts only that the floor check passes, not that the whole migration
|
||||
// succeeds. The store here has a current schema relabelled as 0.22.0, so
|
||||
// replaying the 0.22-onward migrations against it legitimately fails on tables
|
||||
// that were since renamed. Verifying a real 0.22 replay needs a genuine 0.22
|
||||
// database, which is the container tier's job.
|
||||
func TestMigrationAcceptsMinimumSupportedVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
ts := NewTestingStore(ctx, t)
|
||||
|
||||
setSchemaVersion(ctx, t, ts, "0.22.0")
|
||||
|
||||
if err := ts.Migrate(ctx); err != nil {
|
||||
require.NotContains(t, err.Error(), "too old to upgrade directly",
|
||||
"0.22.0 is the supported floor and must clear the minimum-version check")
|
||||
}
|
||||
}
|
||||
152
store/test/migrator_stable_upgrade_test.go
Normal file
152
store/test/migrator_stable_upgrade_test.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
colorpb "google.golang.org/genproto/googleapis/type/color"
|
||||
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
// TestUpgradeFromPreviousStableCopiesTagsToUserSettings covers the upgrade path
|
||||
// users actually take: previous stable release to the current build, with data
|
||||
// already in the database.
|
||||
//
|
||||
// The 0.30 tag migration is hand-written per driver with three different
|
||||
// conflict forms (INSERT OR IGNORE, INSERT IGNORE, ON CONFLICT DO NOTHING) and
|
||||
// different quoting of the reserved words "user" and "key". The existing
|
||||
// fixture test for it only runs on SQLite, so this test drives the real
|
||||
// previous-stable schema on whichever driver DRIVER selects.
|
||||
func TestUpgradeFromPreviousStableCopiesTagsToUserSettings(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping container-based upgrade test in short mode")
|
||||
}
|
||||
skipIfContainerProviderUnavailable(t)
|
||||
|
||||
ctx := context.Background()
|
||||
driver := getDriverFromEnv()
|
||||
|
||||
cfg, hostDSN := prepareUpgradeFixture(t, driver, StableMemosVersion)
|
||||
t.Logf("Starting Memos %s container for %s schema bootstrap...", cfg.Version, driver)
|
||||
container, err := StartMemosContainer(ctx, cfg)
|
||||
require.NoError(t, err, "failed to start memos %s container", StableMemosVersion)
|
||||
t.Cleanup(func() {
|
||||
if container != nil {
|
||||
_ = container.Terminate(ctx)
|
||||
}
|
||||
})
|
||||
|
||||
legacyStore := NewTestingStoreWithDSN(ctx, t, driver, hostDSN)
|
||||
require.Eventually(t, func() bool {
|
||||
setting, err := legacyStore.GetInstanceBasicSetting(ctx)
|
||||
return err == nil && setting != nil && setting.SchemaVersion != ""
|
||||
}, 45*time.Second, 500*time.Millisecond, "previous stable should initialize its schema")
|
||||
|
||||
settingBeforeUpgrade, err := legacyStore.GetInstanceBasicSetting(ctx)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Schema version written by %s: %s", StableMemosVersion, settingBeforeUpgrade.SchemaVersion)
|
||||
|
||||
require.NoError(t, container.Terminate(ctx), "failed to stop memos %s container", StableMemosVersion)
|
||||
container = nil
|
||||
|
||||
// Seed through the store API. 0.29 shipped no migrations and 0.30 adds no
|
||||
// DDL, so current store code reads and writes the previous stable schema.
|
||||
seedStore := NewTestingStoreWithDSN(ctx, t, driver, hostDSN)
|
||||
|
||||
copiedUser, err := createTestingUserWithRole(ctx, seedStore, "tagcopy", store.RoleUser)
|
||||
require.NoError(t, err)
|
||||
keepsOwnUser, err := createTestingUserWithRole(ctx, seedStore, "keepsown", store.RoleUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = seedStore.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
|
||||
Key: storepb.InstanceSettingKey_TAGS,
|
||||
Value: &storepb.InstanceSetting_TagsSetting{
|
||||
TagsSetting: &storepb.InstanceTagsSetting{
|
||||
Tags: map[string]*storepb.InstanceTagMetadata{
|
||||
"bug": {
|
||||
BackgroundColor: &colorpb.Color{Red: 0.9, Green: 0.1, Blue: 0.1},
|
||||
},
|
||||
"private/.*": {
|
||||
BlurContent: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "should seed the instance-level TAGS setting")
|
||||
|
||||
// One user already has their own TAGS setting; the migration must not clobber it.
|
||||
_, err = seedStore.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||
UserId: keepsOwnUser.ID,
|
||||
Key: storepb.UserSetting_TAGS,
|
||||
Value: &storepb.UserSetting_Tags{
|
||||
Tags: &storepb.TagsUserSetting{
|
||||
Tags: map[string]*storepb.UserTagMetadata{
|
||||
"existing": {BlurContent: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "should seed a pre-existing user TAGS setting")
|
||||
|
||||
// The user that should receive a copy must not have one yet.
|
||||
preExisting, err := seedStore.GetUserSetting(ctx, &store.FindUserSetting{
|
||||
UserID: &copiedUser.ID,
|
||||
Key: storepb.UserSetting_TAGS,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, preExisting, "the seeded user should not have a TAGS setting before the upgrade")
|
||||
|
||||
// Upgrade with current code.
|
||||
ts := NewTestingStoreWithDSN(ctx, t, driver, hostDSN)
|
||||
require.NoError(t, ts.Migrate(ctx), "upgrade from %s should succeed for %s", StableMemosVersion, driver)
|
||||
|
||||
currentVersion, err := ts.GetCurrentSchemaVersion()
|
||||
require.NoError(t, err)
|
||||
upgradedSetting, err := ts.GetInstanceBasicSetting(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, currentVersion, upgradedSetting.SchemaVersion, "schema version should advance")
|
||||
|
||||
// The instance tags should now exist on the user that had none.
|
||||
copied, err := ts.GetUserSetting(ctx, &store.FindUserSetting{
|
||||
UserID: &copiedUser.ID,
|
||||
Key: storepb.UserSetting_TAGS,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, copied, "instance tags should be copied to the user")
|
||||
require.Contains(t, copied.GetTags().GetTags(), "bug")
|
||||
bugMetadata := copied.GetTags().GetTags()["bug"]
|
||||
require.NotNil(t, bugMetadata.GetBackgroundColor())
|
||||
require.InDelta(t, 0.9, bugMetadata.GetBackgroundColor().GetRed(), 1e-6)
|
||||
require.InDelta(t, 0.1, bugMetadata.GetBackgroundColor().GetGreen(), 1e-6)
|
||||
require.InDelta(t, 0.1, bugMetadata.GetBackgroundColor().GetBlue(), 1e-6)
|
||||
require.True(t, copied.GetTags().GetTags()["private/.*"].GetBlurContent())
|
||||
|
||||
// The user with their own setting keeps it untouched.
|
||||
kept, err := ts.GetUserSetting(ctx, &store.FindUserSetting{
|
||||
UserID: &keepsOwnUser.ID,
|
||||
Key: storepb.UserSetting_TAGS,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, kept)
|
||||
require.Contains(t, kept.GetTags().GetTags(), "existing")
|
||||
require.NotContains(t, kept.GetTags().GetTags(), "bug", "existing user tags should not be overwritten")
|
||||
|
||||
// Re-running the upgrade must stay a no-op, which is what a container
|
||||
// restart on an already-upgraded volume does.
|
||||
require.NoError(t, ts.Migrate(ctx), "re-running the upgrade should be idempotent")
|
||||
|
||||
// The upgraded database must still accept writes.
|
||||
postUpgradeMemo, err := ts.CreateMemo(ctx, &store.Memo{
|
||||
UID: "post-stable-upgrade-memo",
|
||||
CreatorID: copiedUser.ID,
|
||||
Content: "created after upgrading from previous stable",
|
||||
Visibility: store.Private,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "created after upgrading from previous stable", postUpgradeMemo.Content)
|
||||
}
|
||||
|
|
@ -119,8 +119,13 @@ func TestMigrationFromV0262PreservesLegacyData(t *testing.T) {
|
|||
|
||||
func prepareV0262MigrationTest(t *testing.T, driver string) (MemosContainerConfig, string) {
|
||||
t.Helper()
|
||||
return prepareUpgradeFixture(t, driver, "0.26.2")
|
||||
}
|
||||
|
||||
const version = "0.26.2"
|
||||
// prepareUpgradeFixture returns the container config needed to bootstrap a real
|
||||
// schema for the given Memos version, plus the DSN the host uses to reach it.
|
||||
func prepareUpgradeFixture(t *testing.T, driver, version string) (MemosContainerConfig, string) {
|
||||
t.Helper()
|
||||
|
||||
switch driver {
|
||||
case "sqlite":
|
||||
|
|
|
|||
Loading…
Reference in a new issue