523 lines
20 KiB
Go
523 lines
20 KiB
Go
// 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"
|
|
storepb "github.com/usememos/memos/proto/gen/store"
|
|
"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 canonical external instance URL. On the first boot of
|
|
// a database without ACCESS, it is also the legacy compatibility input used
|
|
// to initialize that dedicated setting exactly once.
|
|
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(), "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 a private instance 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: ""})
|
|
|
|
accessSetting, err := inst.server.Store.GetInstanceAccessSetting(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, storepb.InstanceAccessMode_INSTANCE_ACCESS_MODE_PRIVATE, accessSetting.AccessMode)
|
|
|
|
// 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")
|
|
}
|
|
|
|
// TestStartupInitializesLegacyAccessOnce verifies the compatibility bridge from
|
|
// the former instance-URL-derived policy to the database-backed ACCESS setting.
|
|
// Later URL changes must not silently change authorization behavior.
|
|
func TestStartupInitializesLegacyAccessOnce(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("public remains public after URL removal", func(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
first := bootInstance(ctx, t, instanceOptions{instanceURL: "https://memos.example.com", dataDir: dataDir})
|
|
accessSetting, err := first.server.Store.GetInstanceAccessSetting(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, storepb.InstanceAccessMode_INSTANCE_ACCESS_MODE_PUBLIC, accessSetting.AccessMode)
|
|
first.shutdown(ctx)
|
|
|
|
second := bootInstance(ctx, t, instanceOptions{instanceURL: "", dataDir: dataDir})
|
|
accessSetting, err = second.server.Store.GetInstanceAccessSetting(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, storepb.InstanceAccessMode_INSTANCE_ACCESS_MODE_PUBLIC, accessSetting.AccessMode)
|
|
|
|
status, body := second.do(t, http.MethodGet, "/api/v1/memos", "", nil)
|
|
require.Equal(t, http.StatusOK, status, "persisted PUBLIC mode should still allow anonymous access: %s", body)
|
|
})
|
|
|
|
t.Run("private remains private after URL addition", func(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
first := bootInstance(ctx, t, instanceOptions{instanceURL: "", dataDir: dataDir})
|
|
accessSetting, err := first.server.Store.GetInstanceAccessSetting(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, storepb.InstanceAccessMode_INSTANCE_ACCESS_MODE_PRIVATE, accessSetting.AccessMode)
|
|
first.shutdown(ctx)
|
|
|
|
second := bootInstance(ctx, t, instanceOptions{instanceURL: "https://memos.example.com", dataDir: dataDir})
|
|
accessSetting, err = second.server.Store.GetInstanceAccessSetting(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, storepb.InstanceAccessMode_INSTANCE_ACCESS_MODE_PRIVATE, accessSetting.AccessMode)
|
|
|
|
status, _ := second.do(t, http.MethodGet, "/api/v1/memos", "", nil)
|
|
require.Equal(t, http.StatusUnauthorized, status, "canonical URL must not reopen a persisted PRIVATE instance")
|
|
})
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
// TestStartupGatewayOmitsNullMessageFields checks the JSON the gateway actually
|
|
// puts on the wire against the contract the generated OpenAPI schema publishes.
|
|
// grpc-gateway's stock marshaler emits `"motionMedia": null` for a plain image
|
|
// attachment; the schema types that field as an object, so clients validating
|
|
// responses against it — MCP clients serve that schema as the tool outputSchema
|
|
// — reject every memo carrying an attachment. The marshaler override lives in
|
|
// RegisterGateway, so only a booted server proves it is wired in.
|
|
func TestStartupGatewayOmitsNullMessageFields(t *testing.T) {
|
|
ctx := context.Background()
|
|
inst := bootInstance(ctx, t, instanceOptions{instanceURL: "http://localhost"})
|
|
|
|
inst.createAdmin(t)
|
|
token := inst.signIn(t)
|
|
|
|
// A 1x1 GIF: small enough to inline, real enough for the type sniffing the
|
|
// attachment service runs on upload.
|
|
const onePixelGIF = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
|
status, body := inst.do(t, http.MethodPost, "/api/v1/attachments", token, map[string]any{
|
|
"filename": "pixel.gif",
|
|
"type": "image/gif",
|
|
"content": onePixelGIF,
|
|
})
|
|
require.Equal(t, http.StatusOK, status, "creating an attachment should succeed: %s", body)
|
|
|
|
var created struct {
|
|
Name string `json:"name"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(body, &created))
|
|
require.NotEmpty(t, created.Name, "the created attachment should carry a name: %s", body)
|
|
|
|
status, body = inst.do(t, http.MethodGet, "/api/v1/"+created.Name, token, nil)
|
|
require.Equal(t, http.StatusOK, status, "reading the attachment should succeed: %s", body)
|
|
|
|
fetched := map[string]any{}
|
|
require.NoError(t, json.Unmarshal(body, &fetched))
|
|
require.NotContains(t, fetched, "motionMedia", "an unset message field must be omitted, not null: %s", body)
|
|
// Scalar defaults still ship, so schema-required fields stay present.
|
|
require.Equal(t, "", fetched["externalLink"])
|
|
require.Equal(t, "pixel.gif", fetched["filename"])
|
|
require.Equal(t, "image/gif", fetched["type"])
|
|
}
|
|
|
|
// 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")
|
|
}
|