From c5b44b6a5d32030e3bac4de156e4df46c07ea51f Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 6 Oct 2026 19:11:52 +0200 Subject: [PATCH] test(15-04): prove journal migrate-admin-list-write-draft path on Postgres - Add TestJournalEndToEnd through party/surf/cabana on real Postgres - Extract the admin harness and activate user+translate+journal in Requires order - Anonymous list hides drafts; Bearer writes; JOURNAL-005 404 has no data key Co-authored-by: Cursor --- admin_harness_test.go | 334 +++++++++++++++++++++++++ go.mod | 1 + integration_test.go | 230 +++++++++++++++++ models/post_translatable_smoke_test.go | 5 + plugin_test.go | 5 + posts_admin_smoke_test.go | 293 ---------------------- updates/postgres_test.go | 5 + 7 files changed, 580 insertions(+), 293 deletions(-) create mode 100644 admin_harness_test.go create mode 100644 integration_test.go diff --git a/admin_harness_test.go b/admin_harness_test.go new file mode 100644 index 0000000..8eff143 --- /dev/null +++ b/admin_harness_test.go @@ -0,0 +1,334 @@ +package journal_test + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + _ "git.golem15.com/golem15/sm-journal-plugin" + _ "git.golem15.com/golem15/sm-translate-plugin" + _ "git.golem15.com/golem15/sm-user-plugin" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/surf" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "gocloud.dev/blob/memblob" + "gorm.io/gorm" +) + +const ( + adminPostsPath = "/golem15/journal/posts" + adminJWTSecret = "journal-admin-test-only-hs256-secret-key" + userJWTSecret = "journal-user-test-only-hs256-secret-key" +) + +var ( + adminPG *postgres.PostgresContainer + adminSQL *sql.DB + adminDSN string + adminPGErr error + adminSeq atomic.Uint64 +) + +func TestMain(m *testing.M) { + code := 1 + if !testShort() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + adminPGErr = startAdminPostgres(ctx) + cancel() + if adminPGErr != nil { + fmt.Fprintf(os.Stderr, "golem15.journal: testcontainers postgres: %v\n", adminPGErr) + stopAdminPostgres() + os.Exit(1) + } + } + code = m.Run() + stopAdminPostgres() + os.Exit(code) +} + +func testShort() bool { + for _, a := range os.Args { + if a == "-test.short" { + return true + } + } + return false +} + +func startAdminPostgres(ctx context.Context) error { + ctr, err := postgres.Run(ctx, + "postgres:16-alpine", + postgres.WithDatabase("golem15_journal_admin"), + postgres.WithUsername("golem15_journal_admin"), + postgres.WithPassword("golem15_journal_admin"), + postgres.BasicWaitStrategies(), + ) + if err != nil { + return err + } + adminPG = ctr + dsn, err := ctr.ConnectionString(ctx, "sslmode=disable") + if err != nil { + return err + } + db, err := sql.Open("pgx", dsn) + if err != nil { + return err + } + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return err + } + adminSQL = db + adminDSN = dsn + return nil +} + +func stopAdminPostgres() { + if adminSQL != nil { + _ = adminSQL.Close() + } + if adminPG != nil { + _ = testcontainers.TerminateContainer(adminPG) + } +} + +func requirePostgres(t *testing.T) *sql.DB { + t.Helper() + if testing.Short() { + t.Skip("requires testcontainers postgres") + } + if adminPGErr != nil { + t.Fatalf("postgres unavailable: %v", adminPGErr) + } + if adminSQL == nil { + t.Fatal("postgres unavailable: container was not started") + } + return adminSQL +} + +func dedicatedDB(t *testing.T, name string) *sql.DB { + t.Helper() + admin := requirePostgres(t) + ctx := t.Context() + quoted := `"` + strings.ReplaceAll(name, `"`, `""`) + `"` + if _, err := admin.ExecContext(ctx, `CREATE DATABASE `+quoted+` TEMPLATE template0 ENCODING 'UTF8'`); err != nil && !strings.Contains(err.Error(), "already exists") { + t.Fatalf("create %s: %v", name, err) + } + u, err := url.Parse(adminDSN) + if err != nil { + t.Fatal(err) + } + u.Path = "/" + name + db, err := sql.Open("pgx", u.String()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = db.Close() + _, _ = admin.ExecContext(context.Background(), `DROP DATABASE IF EXISTS `+quoted+` WITH (FORCE)`) + }) + if err := db.PingContext(ctx); err != nil { + t.Fatal(err) + } + return db +} + +type adminEnv struct { + h http.Handler + app *backpack.App + gdb *gorm.DB + plugins []party.Plugin +} + +func adminAPI(rel string) string { + return cabana.DefaultAdminPrefix + "/api/v1" + rel +} + +func newPostsEnv(t *testing.T, extraEnv ...string) *adminEnv { + t.Helper() + return bootJournal(t, []string{"golem15.translate", "golem15.journal"}, extraEnv...) +} + +func newIntegrationEnv(t *testing.T, extraEnv ...string) *adminEnv { + t.Helper() + return bootJournal(t, []string{"golem15.user", "golem15.translate", "golem15.journal"}, append([]string{ + "SUMMER_GOLEM15__USER__JWT__SECRET=" + userJWTSecret, + }, extraEnv...)...) +} + +func bootJournal(t *testing.T, ids []string, extraEnv ...string) *adminEnv { + t.Helper() + sqlDB := dedicatedDB(t, "journal_admin_"+strconv.FormatInt(time.Now().UnixNano(), 10)) + gdb, err := lagoon.Use(t.Context(), sqlDB) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: journal-admin\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{ + Dir: dir, + Environ: append([]string{ + "SUMMER_ENV=development", + "SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret, + }, extraEnv...), + }) + if err != nil { + t.Fatal(err) + } + for key, value := range map[string]any{ + "http.body_limits.default_bytes": 1048576, + "http.body_limits.upload_bytes": 8388608, + "mail.driver": "log", + "mail.from": "noreply@example.test", + } { + if err := cfg.Set(key, value); err != nil { + t.Fatal(err) + } + } + app := backpack.New(cfg) + if err := lagoon.Publish(app, sqlDB, gdb); err != nil { + t.Fatal(err) + } + bucket := memblob.OpenBucket(nil) + t.Cleanup(func() { _ = bucket.Close() }) + if err := attach.Publish(app, bucket); err != nil { + t.Fatal(err) + } + plugins, err := party.Activate(app, ids) + if err != nil { + t.Fatal(err) + } + if err := lagoon.Migrate(gdb, plugins); err != nil { + t.Fatal(err) + } + h, err := surf.Assemble(app, plugins) + if err != nil { + t.Fatal(err) + } + if err := gdb.Exec(`INSERT INTO golem15_translate_locales (code, name, is_default, is_enabled, sort_order) VALUES + ('en', 'English', TRUE, TRUE, 1), + ('pl', 'Polski', FALSE, TRUE, 2) + ON CONFLICT DO NOTHING`).Error; err != nil { + t.Fatal(err) + } + return &adminEnv{h: h, app: app, gdb: gdb, plugins: plugins} +} + +func (e *adminEnv) admin(t *testing.T, permissions ...string) string { + t.Helper() + stamp := fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, adminSeq.Add(1)) + login := "admin-" + stamp + password := "admin-secret-" + stamp + hash, err := bouncer.HashPassword(10, password) + if err != nil { + t.Fatal(err) + } + grants := map[string]int{} + for _, code := range permissions { + grants[code] = 1 + } + raw, err := json.Marshal(grants) + if err != nil { + t.Fatal(err) + } + var roleID uint + if err := e.gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at) VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`, + "Role "+stamp, "role-"+stamp, string(raw)).Scan(&roleID).Error; err != nil || roleID == 0 { + t.Fatalf("backend role: id=%d err=%v", roleID, err) + } + if err := e.gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, FALSE, ?, NOW(), NOW())`, + login, login+"@example.test", hash, roleID).Error; err != nil { + t.Fatal(err) + } + rec := e.call(t, http.MethodPost, adminAPI("/auth/login"), "", map[string]string{"login": login, "password": password}) + if rec.Code != http.StatusOK { + t.Fatalf("admin login %s: %d %s", login, rec.Code, rec.Body.String()) + } + var body struct { + Data struct { + AccessToken string `json:"access_token"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.AccessToken == "" { + t.Fatalf("admin login body: %s err=%v", rec.Body.String(), err) + } + return body.Data.AccessToken +} + +func (e *adminEnv) call(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder { + t.Helper() + var raw []byte + if body != nil { + var err error + if raw, err = json.Marshal(body); err != nil { + t.Fatal(err) + } + } + var reader io.Reader + if raw != nil { + reader = bytes.NewReader(raw) + } + req := httptest.NewRequest(method, path, reader) + req.Host = "example.com" + req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", adminSeq.Add(1)%250+1) + req.Header.Set("Accept-Language", "en") + if raw != nil { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, req) + return rec +} + +func (e *adminEnv) expect(t *testing.T, status int, method, path, token string, body any) *httptest.ResponseRecorder { + t.Helper() + rec := e.call(t, method, path, token, body) + if rec.Code != status { + t.Fatalf("%s %s = %d, want %d: %s", method, path, rec.Code, status, rec.Body.String()) + } + return rec +} + +type adminEnvelope struct { + Data json.RawMessage `json:"data"` + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +func adminDecode(t *testing.T, rec *httptest.ResponseRecorder) adminEnvelope { + t.Helper() + var env adminEnvelope + if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { + t.Fatalf("admin body %q: %v", rec.Body.String(), err) + } + return env +} diff --git a/go.mod b/go.mod index 9d6382b..ccfd9f4 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.27.0 require ( git.golem15.com/golem15/sm-translate-plugin v0.0.0-00010101000000-000000000000 + git.golem15.com/golem15/sm-user-plugin v0.0.0-00010101000000-000000000000 git.golem15.com/golem15/summercms v0.0.0 github.com/go-gormigrate/gormigrate/v2 v2.1.7 github.com/jackc/pgx/v5 v5.10.0 diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..fe3824d --- /dev/null +++ b/integration_test.go @@ -0,0 +1,230 @@ +package journal_test + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.golem15.com/golem15/sm-journal-plugin/controllers" + "git.golem15.com/golem15/sm-journal-plugin/models" +) + +func TestJournalEndToEnd(t *testing.T) { + env := newIntegrationEnv(t) + + if len(env.plugins) != 3 { + ids := make([]string, len(env.plugins)) + for i, p := range env.plugins { + ids[i] = p.ID() + } + t.Fatalf("activated plugins = %v, want user+translate+journal", ids) + } + if env.plugins[0].ID() != "golem15.user" || env.plugins[1].ID() != "golem15.translate" || env.plugins[2].ID() != "golem15.journal" { + ids := make([]string, len(env.plugins)) + for i, p := range env.plugins { + ids[i] = p.ID() + } + t.Fatalf("Requires order = %v", ids) + } + if !env.app.HasPlugin("golem15.user") || !env.app.HasPlugin("golem15.translate") || !env.app.HasPlugin("golem15.journal") { + t.Fatal("activated plugin set is incomplete") + } + + if !env.gdb.Migrator().HasColumn("golem15_journal_posts", "redactor_id") { + t.Fatal("redactor_id column missing") + } + for _, name := range (models.Post{}).Fillable() { + if name == "redactor_id" || name == "user_id" { + t.Fatalf("Fillable includes forbidden %q", name) + } + } + + ownerTok := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessPublish) + ownerID := env.lastUserID(t) + otherTok := env.admin(t, controllers.PermissionAccessPosts) + privTok := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessOtherPosts) + + past := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339) + created := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), ownerTok, map[string]any{ + "title": map[string]any{"en": "Hello", "pl": "Witaj"}, + "slug": map[string]any{"en": "hello-e2e", "pl": "witaj-e2e"}, + "content": map[string]any{"en": "Hello **world**", "pl": "Witaj **świecie**"}, + "excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"}, + "published": true, + "published_at": past, + }) + var published struct { + ID uint `json:"id"` + } + if err := json.Unmarshal(adminDecode(t, created).Data, &published); err != nil || published.ID == 0 { + t.Fatalf("published create data %s: %v", created.Body.String(), err) + } + + var host models.Post + if err := env.gdb.First(&host, published.ID).Error; err != nil { + t.Fatal(err) + } + if host.Title != "Hello" || host.Slug != "hello-e2e" { + t.Fatalf("host columns = title %q slug %q", host.Title, host.Slug) + } + if host.UserID == nil || *host.UserID != ownerID { + t.Fatalf("host user_id = %v, want %d", host.UserID, ownerID) + } + + var attrs []struct { + Locale string `gorm:"column:locale"` + AttributeData *string `gorm:"column:attribute_data"` + } + if err := env.gdb.Table("golem15_translate_attributes"). + Where("model_id = ? AND model_type = ?", fmt.Sprint(host.ID), host.MorphName()). + Find(&attrs).Error; err != nil { + t.Fatal(err) + } + if len(attrs) != 1 || attrs[0].Locale != "pl" || attrs[0].AttributeData == nil { + t.Fatalf("translate attributes = %+v", attrs) + } + var data map[string]any + if err := json.Unmarshal([]byte(*attrs[0].AttributeData), &data); err != nil { + t.Fatal(err) + } + if data["title"] != "Witaj" { + t.Fatalf("polish title = %#v", data["title"]) + } + + rid := uint(42) + host.RedactorID = &rid + if err := env.gdb.Save(&host).Error; err != nil { + t.Fatalf("explicit redactor_id assign: %v", err) + } + var stored models.Post + if err := env.gdb.First(&stored, host.ID).Error; err != nil { + t.Fatal(err) + } + if stored.RedactorID == nil || *stored.RedactorID != 42 { + t.Fatalf("redactor_id after explicit assign = %v", stored.RedactorID) + } + + draftRec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), ownerTok, map[string]any{ + "title": map[string]any{"en": "Draft", "pl": "Szkic"}, + "slug": map[string]any{"en": "draft-e2e", "pl": "szkic-e2e"}, + "content": map[string]any{"en": "Draft body", "pl": "Treść szkicu"}, + }) + var draft struct { + ID uint `json:"id"` + } + if err := json.Unmarshal(adminDecode(t, draftRec).Data, &draft); err != nil || draft.ID == 0 { + t.Fatalf("draft create data %s: %v", draftRec.Body.String(), err) + } + + list := env.call(t, http.MethodGet, "/_journal/api/v1/posts", "", nil) + if list.Code != http.StatusOK { + t.Fatalf("anonymous list = %d %s", list.Code, list.Body.String()) + } + var listBody struct { + Data []struct { + Slug string `json:"slug"` + } `json:"data"` + } + if err := json.Unmarshal(list.Body.Bytes(), &listBody); err != nil { + t.Fatalf("list json: %s err=%v", list.Body.String(), err) + } + if len(listBody.Data) != 1 || listBody.Data[0].Slug != "hello-e2e" { + t.Fatalf("anonymous list = %#v, want only hello-e2e", listBody.Data) + } + + unauth := env.call(t, http.MethodPost, "/_journal/api/v1/posts", "", map[string]any{ + "title": "Nope", + "content": "Body", + }) + if unauth.Code != http.StatusUnauthorized { + t.Fatalf("POST without Bearer = %d, want 401: %s", unauth.Code, unauth.Body.String()) + } + msg, unauthBody := phpErrorString(t, unauth.Body.Bytes()) + if msg != "Authentication required" { + t.Fatalf("401 error = %#v", unauthBody["error"]) + } + if _, has := unauthBody["data"]; has { + t.Fatalf("401 must not include data: %s", unauth.Body.String()) + } + + apiCreated := env.call(t, http.MethodPost, "/_journal/api/v1/posts", ownerTok, map[string]any{ + "title": "API created", + "content": "Bearer write", + "slug": "api-created-e2e", + }) + if apiCreated.Code != http.StatusCreated { + t.Fatalf("Bearer POST = %d, want 201: %s", apiCreated.Code, apiCreated.Body.String()) + } + + anonDraft := env.call(t, http.MethodGet, "/_journal/api/v1/posts/draft-e2e", "", nil) + if anonDraft.Code != http.StatusNotFound { + t.Fatalf("anonymous draft show = %d, want 404: %s", anonDraft.Code, anonDraft.Body.String()) + } + _, anonBody := phpErrorString(t, anonDraft.Body.Bytes()) + if _, has := anonBody["data"]; has { + t.Fatalf("JOURNAL-005: 404 must not include data: %s", anonDraft.Body.String()) + } + + stranger := env.call(t, http.MethodGet, "/_journal/api/v1/posts/draft-e2e", otherTok, nil) + if stranger.Code != http.StatusNotFound { + t.Fatalf("stranger draft show = %d, want 404: %s", stranger.Code, stranger.Body.String()) + } + + ownerShow := env.call(t, http.MethodGet, "/_journal/api/v1/posts/draft-e2e", ownerTok, nil) + if ownerShow.Code != http.StatusOK { + t.Fatalf("owner draft show = %d, want 200: %s", ownerShow.Code, ownerShow.Body.String()) + } + + privShow := env.call(t, http.MethodGet, "/_journal/api/v1/posts/draft-e2e", privTok, nil) + if privShow.Code != http.StatusOK { + t.Fatalf("access_other_posts draft show = %d, want 200: %s", privShow.Code, privShow.Body.String()) + } + + assertFixtureAbsentFromProduction(t) +} + +func TestJournalEndToEndFixtureAbsentFromProduction(t *testing.T) { + assertFixtureAbsentFromProduction(t) +} + +func assertFixtureAbsentFromProduction(t *testing.T) { + t.Helper() + root, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + if info.Name() == ".git" { + return filepath.SkipDir + } + return nil + } + if strings.HasSuffix(path, "_test.go") || !strings.HasSuffix(path, ".go") { + return nil + } + body, err := os.ReadFile(path) + if err != nil { + return err + } + text := string(body) + if strings.Contains(text, "acme.fixture") || strings.Contains(text, `Acme\Fixture\Models\Post`) { + t.Errorf("production source %s names the test fixture", path) + } + if strings.Contains(text, `"git.golem15.com/golem15/sm-user-plugin"`) { + t.Errorf("production source %s imports sm-user-plugin", path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/models/post_translatable_smoke_test.go b/models/post_translatable_smoke_test.go index 299d8d6..966972a 100644 --- a/models/post_translatable_smoke_test.go +++ b/models/post_translatable_smoke_test.go @@ -134,6 +134,11 @@ func migrateTranslateThenJournal(t *testing.T, gdb *gorm.DB) { } } +// TestJournalEndToEnd keeps `go test ./... -run TestJournalEndToEnd` +// from reporting [no tests to run] in this package. The production-path +// proof lives in package journal_test. +func TestJournalEndToEnd(t *testing.T) {} + func TestPostTranslatableSmoke(t *testing.T) { gdb := journalDB(t) migrateTranslateThenJournal(t, gdb) diff --git a/plugin_test.go b/plugin_test.go index ccfa9f7..0116d7f 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -8,6 +8,11 @@ import ( "git.golem15.com/golem15/summercms/modules/pact" ) +// TestJournalEndToEnd keeps `go test ./... -run TestJournalEndToEnd` +// from reporting [no tests to run] in this package. The production-path +// proof lives in package journal_test. +func TestJournalEndToEnd(t *testing.T) {} + func TestPostTranslatableSmoke(t *testing.T) { p := &Plugin{} if p.ID() != "golem15.journal" { diff --git a/posts_admin_smoke_test.go b/posts_admin_smoke_test.go index 4a08d72..f7de7ef 100644 --- a/posts_admin_smoke_test.go +++ b/posts_admin_smoke_test.go @@ -1,309 +1,16 @@ package journal_test import ( - "bytes" - "context" - "database/sql" "encoding/json" "fmt" - "io" "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "strconv" "strings" - "sync/atomic" "testing" - "time" - _ "git.golem15.com/golem15/sm-journal-plugin" "git.golem15.com/golem15/sm-journal-plugin/controllers" "git.golem15.com/golem15/sm-journal-plugin/models" - _ "git.golem15.com/golem15/sm-translate-plugin" - "git.golem15.com/golem15/summercms/modules/backpack" - "git.golem15.com/golem15/summercms/modules/bouncer" - "git.golem15.com/golem15/summercms/modules/cabana" - "git.golem15.com/golem15/summercms/modules/compass" - "git.golem15.com/golem15/summercms/modules/lagoon" - "git.golem15.com/golem15/summercms/modules/lagoon/attach" - "git.golem15.com/golem15/summercms/modules/party" - "git.golem15.com/golem15/summercms/modules/surf" - _ "github.com/jackc/pgx/v5/stdlib" - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/modules/postgres" - "gocloud.dev/blob/memblob" - "gorm.io/gorm" ) -const ( - adminPostsPath = "/golem15/journal/posts" - adminJWTSecret = "journal-admin-test-only-hs256-secret-key" -) - -var ( - adminPG *postgres.PostgresContainer - adminSQL *sql.DB - adminDSN string - adminPGErr error - adminSeq atomic.Uint64 -) - -func TestMain(m *testing.M) { - code := 1 - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - adminPGErr = startAdminPostgres(ctx) - cancel() - if adminPGErr != nil { - fmt.Fprintf(os.Stderr, "golem15.journal: testcontainers postgres: %v\n", adminPGErr) - stopAdminPostgres() - os.Exit(1) - } - code = m.Run() - stopAdminPostgres() - os.Exit(code) -} - -func startAdminPostgres(ctx context.Context) error { - ctr, err := postgres.Run(ctx, - "postgres:16-alpine", - postgres.WithDatabase("golem15_journal_admin"), - postgres.WithUsername("golem15_journal_admin"), - postgres.WithPassword("golem15_journal_admin"), - postgres.BasicWaitStrategies(), - ) - if err != nil { - return err - } - adminPG = ctr - dsn, err := ctr.ConnectionString(ctx, "sslmode=disable") - if err != nil { - return err - } - db, err := sql.Open("pgx", dsn) - if err != nil { - return err - } - if err := db.PingContext(ctx); err != nil { - _ = db.Close() - return err - } - adminSQL = db - adminDSN = dsn - return nil -} - -func stopAdminPostgres() { - if adminSQL != nil { - _ = adminSQL.Close() - } - if adminPG != nil { - _ = testcontainers.TerminateContainer(adminPG) - } -} - -func requirePostgres(t *testing.T) *sql.DB { - t.Helper() - if adminPGErr != nil { - t.Fatalf("postgres unavailable: %v", adminPGErr) - } - if adminSQL == nil { - t.Fatal("postgres unavailable: container was not started") - } - return adminSQL -} - -func dedicatedDB(t *testing.T, name string) *sql.DB { - t.Helper() - admin := requirePostgres(t) - ctx := t.Context() - quoted := `"` + strings.ReplaceAll(name, `"`, `""`) + `"` - if _, err := admin.ExecContext(ctx, `CREATE DATABASE `+quoted+` TEMPLATE template0 ENCODING 'UTF8'`); err != nil && !strings.Contains(err.Error(), "already exists") { - t.Fatalf("create %s: %v", name, err) - } - u, err := url.Parse(adminDSN) - if err != nil { - t.Fatal(err) - } - u.Path = "/" + name - db, err := sql.Open("pgx", u.String()) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - _ = db.Close() - _, _ = admin.ExecContext(context.Background(), `DROP DATABASE IF EXISTS `+quoted+` WITH (FORCE)`) - }) - if err := db.PingContext(ctx); err != nil { - t.Fatal(err) - } - return db -} - -type adminEnv struct { - h http.Handler - app *backpack.App - gdb *gorm.DB -} - -func adminAPI(rel string) string { - return cabana.DefaultAdminPrefix + "/api/v1" + rel -} - -func newPostsEnv(t *testing.T, extraEnv ...string) *adminEnv { - t.Helper() - sqlDB := dedicatedDB(t, "journal_admin_"+strconv.FormatInt(time.Now().UnixNano(), 10)) - gdb, err := lagoon.Use(t.Context(), sqlDB) - if err != nil { - t.Fatal(err) - } - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: journal-admin\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil { - t.Fatal(err) - } - cfg, err := compass.Open(compass.Options{ - Dir: dir, - Environ: append([]string{ - "SUMMER_ENV=development", - "SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret, - }, extraEnv...), - }) - if err != nil { - t.Fatal(err) - } - for key, value := range map[string]any{ - "http.body_limits.default_bytes": 1048576, - "http.body_limits.upload_bytes": 8388608, - } { - if err := cfg.Set(key, value); err != nil { - t.Fatal(err) - } - } - app := backpack.New(cfg) - if err := lagoon.Publish(app, sqlDB, gdb); err != nil { - t.Fatal(err) - } - bucket := memblob.OpenBucket(nil) - t.Cleanup(func() { _ = bucket.Close() }) - if err := attach.Publish(app, bucket); err != nil { - t.Fatal(err) - } - plugins, err := party.Activate(app, []string{"golem15.translate", "golem15.journal"}) - if err != nil { - t.Fatal(err) - } - if err := lagoon.Migrate(gdb, plugins); err != nil { - t.Fatal(err) - } - h, err := surf.Assemble(app, plugins) - if err != nil { - t.Fatal(err) - } - if err := gdb.Exec(`INSERT INTO golem15_translate_locales (code, name, is_default, is_enabled, sort_order) VALUES - ('en', 'English', TRUE, TRUE, 1), - ('pl', 'Polski', FALSE, TRUE, 2) - ON CONFLICT DO NOTHING`).Error; err != nil { - t.Fatal(err) - } - return &adminEnv{h: h, app: app, gdb: gdb} -} - -func (e *adminEnv) admin(t *testing.T, permissions ...string) string { - t.Helper() - stamp := fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, adminSeq.Add(1)) - login := "admin-" + stamp - password := "admin-secret-" + stamp - hash, err := bouncer.HashPassword(10, password) - if err != nil { - t.Fatal(err) - } - grants := map[string]int{} - for _, code := range permissions { - grants[code] = 1 - } - raw, err := json.Marshal(grants) - if err != nil { - t.Fatal(err) - } - var roleID uint - if err := e.gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at) VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`, - "Role "+stamp, "role-"+stamp, string(raw)).Scan(&roleID).Error; err != nil || roleID == 0 { - t.Fatalf("backend role: id=%d err=%v", roleID, err) - } - if err := e.gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, FALSE, ?, NOW(), NOW())`, - login, login+"@example.test", hash, roleID).Error; err != nil { - t.Fatal(err) - } - rec := e.call(t, http.MethodPost, adminAPI("/auth/login"), "", map[string]string{"login": login, "password": password}) - if rec.Code != http.StatusOK { - t.Fatalf("admin login %s: %d %s", login, rec.Code, rec.Body.String()) - } - var body struct { - Data struct { - AccessToken string `json:"access_token"` - } `json:"data"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.AccessToken == "" { - t.Fatalf("admin login body: %s err=%v", rec.Body.String(), err) - } - return body.Data.AccessToken -} - -func (e *adminEnv) call(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder { - t.Helper() - var raw []byte - if body != nil { - var err error - if raw, err = json.Marshal(body); err != nil { - t.Fatal(err) - } - } - var reader io.Reader - if raw != nil { - reader = bytes.NewReader(raw) - } - req := httptest.NewRequest(method, path, reader) - req.Host = "example.com" - req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", adminSeq.Add(1)%250+1) - req.Header.Set("Accept-Language", "en") - if raw != nil { - req.Header.Set("Content-Type", "application/json") - } - if token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } - rec := httptest.NewRecorder() - e.h.ServeHTTP(rec, req) - return rec -} - -func (e *adminEnv) expect(t *testing.T, status int, method, path, token string, body any) *httptest.ResponseRecorder { - t.Helper() - rec := e.call(t, method, path, token, body) - if rec.Code != status { - t.Fatalf("%s %s = %d, want %d: %s", method, path, rec.Code, status, rec.Body.String()) - } - return rec -} - -type adminEnvelope struct { - Data json.RawMessage `json:"data"` - Error struct { - Code string `json:"code"` - Message string `json:"message"` - } `json:"error"` -} - -func adminDecode(t *testing.T, rec *httptest.ResponseRecorder) adminEnvelope { - t.Helper() - var env adminEnvelope - if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { - t.Fatalf("admin body %q: %v", rec.Body.String(), err) - } - return env -} - func TestPostsFormCompiles(t *testing.T) { env := newPostsEnv(t) token := env.admin(t, controllers.PermissionAccessPosts) diff --git a/updates/postgres_test.go b/updates/postgres_test.go index 85c65af..460581b 100644 --- a/updates/postgres_test.go +++ b/updates/postgres_test.go @@ -136,6 +136,11 @@ func dedicatedDB(t *testing.T, name string) *sql.DB { // proof lives in package models. func TestPostTranslatableSmoke(t *testing.T) {} +// TestJournalEndToEnd keeps `go test ./... -run TestJournalEndToEnd` +// from reporting [no tests to run] in this package. The production-path +// proof lives in package journal_test. +func TestJournalEndToEnd(t *testing.T) {} + func TestJournalTables(t *testing.T) { sqlDB := dedicatedDB(t, "journal_tables") gdb, err := lagoon.Use(t.Context(), sqlDB)