From 8b270543c9642420b2f96020b21b58f9e2b3ff1d Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:40:07 +0200 Subject: [PATCH 01/12] feat: add command to create a test user for automated testing --- NOTICE | 4 +- cli/cmd/bootstrap_gcp.go | 47 +++- cli/cmd/create.go | 27 +++ cli/cmd/create_test_user.go | 69 ++++++ cli/cmd/create_test_user_test.go | 96 +++++++++ cli/cmd/root.go | 3 + docs/README.md | 1 + docs/oms.md | 1 + docs/oms_beta_bootstrap-gcp.md | 1 + docs/oms_create.md | 19 ++ docs/oms_create_test-user.md | 34 +++ go.mod | 7 +- go.sum | 5 +- internal/bootstrap/gcp/gcp.go | 3 +- internal/installer/node/node.go | 7 +- internal/testuser/hash.go | 39 ++++ internal/testuser/hash_test.go | 98 +++++++++ internal/testuser/testuser.go | 264 +++++++++++++++++++++++ internal/testuser/testuser_suite_test.go | 16 ++ internal/testuser/testuser_test.go | 220 +++++++++++++++++++ internal/tmpl/NOTICE | 4 +- 21 files changed, 952 insertions(+), 13 deletions(-) create mode 100644 cli/cmd/create.go create mode 100644 cli/cmd/create_test_user.go create mode 100644 cli/cmd/create_test_user_test.go create mode 100644 docs/oms_create.md create mode 100644 docs/oms_create_test-user.md create mode 100644 internal/testuser/hash.go create mode 100644 internal/testuser/hash_test.go create mode 100644 internal/testuser/testuser.go create mode 100644 internal/testuser/testuser_suite_test.go create mode 100644 internal/testuser/testuser_test.go diff --git a/NOTICE b/NOTICE index 53cd06a48..6b2076b0e 100644 --- a/NOTICE +++ b/NOTICE @@ -707,9 +707,9 @@ License URL: https://github.com/lann/ps/blob/62de8c46ede0/LICENSE ---------- Module: github.com/lib/pq -Version: v1.12.0 +Version: v1.12.3 License: MIT -License URL: https://github.com/lib/pq/blob/v1.12.0/LICENSE +License URL: https://github.com/lib/pq/blob/v1.12.3/LICENSE ---------- Module: github.com/libopenstorage/secrets diff --git a/cli/cmd/bootstrap_gcp.go b/cli/cmd/bootstrap_gcp.go index 4f06a602c..d219fbc67 100644 --- a/cli/cmd/bootstrap_gcp.go +++ b/cli/cmd/bootstrap_gcp.go @@ -19,6 +19,7 @@ import ( "github.com/codesphere-cloud/oms/internal/installer" "github.com/codesphere-cloud/oms/internal/installer/node" "github.com/codesphere-cloud/oms/internal/portal" + "github.com/codesphere-cloud/oms/internal/testuser" "github.com/codesphere-cloud/oms/internal/util" ) @@ -30,6 +31,7 @@ type BootstrapGcpCmd struct { InputRegistryType string SSHQuiet bool FeatureFlagList []string + CreateTestUser bool } func (c *BootstrapGcpCmd) RunE(_ *cobra.Command, args []string) error { @@ -95,6 +97,7 @@ func AddBootstrapGcpCmd(parent *cobra.Command, opts *GlobalOptions) { flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.WriteConfig, "write-config", true, "Write generated install config to file (default: true)") flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.RecoverConfig, "recover-config", false, "Recover previously generated install config from the jumpbox. This will overwrite the local config! (default: false)") flags.BoolVar(&bootstrapGcpCmd.SSHQuiet, "ssh-quiet", false, "Suppress SSH command output (default: false)") + flags.BoolVar(&bootstrapGcpCmd.CreateTestUser, "create-test-user", false, "Create a test user with API token on the bootstrapped instance for smoke testing (default: false)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.OpenBaoURI, "openbao-uri", "", "URI for OpenBao (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.OpenBaoEngine, "openbao-engine", "cs-secrets-engine", "OpenBao engine name (default: cs-secrets-engine)") @@ -164,7 +167,14 @@ func (c *BootstrapGcpCmd) BootstrapGcp() error { } log.Println("\nšŸŽ‰šŸŽ‰šŸŽ‰ GCP infrastructure bootstrapped successfully!") - log.Printf("Access the jumpbox using:\nssh-add $SSH_KEY_PATH; ssh -o StrictHostKeyChecking=no -o ForwardAgent=yes -o SendEnv=OMS_PORTAL_API_KEY root@%s", bs.Env.Jumpbox.GetExternalIP()) + log.Printf("Access the jumpbox using:\nssh-add $SSH_KEY_PATH; ssh -o StrictHostKeyChecking=no -o ForwardAgent=yes -o SendEnv=OMS_PORTAL_API_KEY -o SendEnv=OMS_PORTAL_API root@%s", bs.Env.Jumpbox.GetExternalIP()) + + if c.CreateTestUser { + if err := c.createTestUser(bs); err != nil { + log.Printf("warning: failed to create test user: %v", err) + } + } + if bs.Env.InstallVersion != "" { log.Printf("Access Codesphere in your web browser at https://cs.%s", bs.Env.BaseDomain) return nil @@ -206,3 +216,38 @@ func writeInfraDetails(csEnv *gcp.CodesphereEnvironment) error { return nil } + +func (c *BootstrapGcpCmd) createTestUser(bs *gcp.GCPBootstrapper) error { + if bs.Env.PostgreSQLNode == nil { + return fmt.Errorf("postgres node not found in bootstrap environment") + } + + pgHost := bs.Env.PostgreSQLNode.GetExternalIP() + if pgHost == "" { + return fmt.Errorf("postgres node has no external IP") + } + + pgPassword := "" + if bs.Env.InstallConfig != nil { + pgPassword = bs.Env.InstallConfig.Postgres.AdminPassword + } + if pgPassword == "" { + return fmt.Errorf("postgres admin password not found in install config") + } + + result, err := testuser.CreateTestUser(testuser.CreateTestUserOpts{ + Host: pgHost, + Port: testuser.DefaultPort, + User: testuser.DefaultUser, + Password: pgPassword, + DBName: testuser.DefaultDBName, + SSLMode: "require", + }) + if err != nil { + return err + } + + testuser.LogAndPersistResult(result, c.Env.GetOmsWorkdir()) + + return nil +} diff --git a/cli/cmd/create.go b/cli/cmd/create.go new file mode 100644 index 000000000..53f45a162 --- /dev/null +++ b/cli/cmd/create.go @@ -0,0 +1,27 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/spf13/cobra" +) + +// CreateCmd represents the create command group +type CreateCmd struct { + cmd *cobra.Command +} + +func AddCreateCmd(rootCmd *cobra.Command, opts *GlobalOptions) { + create := CreateCmd{ + cmd: &cobra.Command{ + Use: "create", + Short: "Create resources for Codesphere", + Long: io.Long(`Create resources for Codesphere installations, such as test users for automated testing.`), + }, + } + AddCmd(rootCmd, create.cmd) + + AddCreateTestUserCmd(create.cmd, opts) +} diff --git a/cli/cmd/create_test_user.go b/cli/cmd/create_test_user.go new file mode 100644 index 000000000..594ac2323 --- /dev/null +++ b/cli/cmd/create_test_user.go @@ -0,0 +1,69 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/testuser" + "github.com/codesphere-cloud/oms/internal/util" +) + +type CreateTestUserCmd struct { + cmd *cobra.Command + Opts CreateTestUserOpts + Env env.Env +} + +type CreateTestUserOpts struct { + *GlobalOptions + testuser.CreateTestUserOpts +} + +func (c *CreateTestUserCmd) RunE(_ *cobra.Command, args []string) error { + result, err := testuser.CreateTestUser(c.Opts.CreateTestUserOpts) + if err != nil { + return fmt.Errorf("failed to create test user: %w", err) + } + + testuser.LogAndPersistResult(result, c.Env.GetOmsWorkdir()) + + return nil +} + +func AddCreateTestUserCmd(parent *cobra.Command, opts *GlobalOptions) { + c := CreateTestUserCmd{ + cmd: &cobra.Command{ + Use: "test-user", + Short: "Create a test user on a Codesphere database", + Long: io.Long(`Creates a test user with a hashed password and API token directly in a Codesphere + PostgreSQL database. The user can be used for automated smoke tests. + + The command connects to the specified PostgreSQL instance and creates the necessary + database records (credentials, email confirmation, team, team membership, API token). + + Credentials are displayed and saved to the OMS workdir as test-user.json.`), + }, + Opts: CreateTestUserOpts{GlobalOptions: opts}, + Env: env.NewEnv(), + } + c.cmd.RunE = c.RunE + + flags := c.cmd.Flags() + flags.StringVar(&c.Opts.Host, "postgres-host", "", "PostgreSQL host address (required)") + flags.IntVar(&c.Opts.Port, "postgres-port", testuser.DefaultPort, "PostgreSQL port") + flags.StringVar(&c.Opts.User, "postgres-user", testuser.DefaultUser, "PostgreSQL username") + flags.StringVar(&c.Opts.Password, "postgres-password", "", "PostgreSQL password (required)") + flags.StringVar(&c.Opts.DBName, "postgres-db", testuser.DefaultDBName, "PostgreSQL database name") + flags.StringVar(&c.Opts.SSLMode, "ssl-mode", testuser.DefaultSSLMode, "PostgreSQL SSL mode") + + util.MarkFlagRequired(c.cmd, "postgres-host") + util.MarkFlagRequired(c.cmd, "postgres-password") + + AddCmd(parent, c.cmd) +} diff --git a/cli/cmd/create_test_user_test.go b/cli/cmd/create_test_user_test.go new file mode 100644 index 000000000..cb74bdffc --- /dev/null +++ b/cli/cmd/create_test_user_test.go @@ -0,0 +1,96 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" + + "github.com/codesphere-cloud/oms/cli/cmd" +) + +var _ = Describe("CreateTestUser", func() { + Context("AddCreateTestUserCmd", func() { + var createCmd cobra.Command + var opts *cmd.GlobalOptions + + BeforeEach(func() { + createCmd = cobra.Command{} + opts = &cmd.GlobalOptions{} + }) + + It("accepts valid flags with all required flags set", func() { + createCmd.SetArgs([]string{ + "test-user", + "--postgres-host", "localhost", + "--postgres-password", "secret", + }) + + cmd.AddCreateTestUserCmd(&createCmd, opts) + + createCmd.Commands()[0].RunE = func(cmd *cobra.Command, args []string) error { + return nil + } + + err := createCmd.Execute() + Expect(err).NotTo(HaveOccurred()) + }) + + It("fails when --postgres-host is missing", func() { + createCmd.SetArgs([]string{ + "test-user", + "--postgres-password", "secret", + }) + + cmd.AddCreateTestUserCmd(&createCmd, opts) + + createCmd.Commands()[0].RunE = func(cmd *cobra.Command, args []string) error { + return nil + } + + err := createCmd.Execute() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("postgres-host")) + }) + + It("fails when --postgres-password is missing", func() { + createCmd.SetArgs([]string{ + "test-user", + "--postgres-host", "localhost", + }) + + cmd.AddCreateTestUserCmd(&createCmd, opts) + + createCmd.Commands()[0].RunE = func(cmd *cobra.Command, args []string) error { + return nil + } + + err := createCmd.Execute() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("postgres-password")) + }) + + It("accepts optional flags with custom values", func() { + createCmd.SetArgs([]string{ + "test-user", + "--postgres-host", "db.example.com", + "--postgres-password", "secret", + "--postgres-port", "5433", + "--postgres-user", "admin", + "--postgres-db", "mydb", + "--ssl-mode", "require", + }) + + cmd.AddCreateTestUserCmd(&createCmd, opts) + + createCmd.Commands()[0].RunE = func(cmd *cobra.Command, args []string) error { + return nil + } + + err := createCmd.Execute() + Expect(err).NotTo(HaveOccurred()) + }) + }) +}) diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 2cc6dd53c..602a9773c 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -84,6 +84,9 @@ func GetRootCmd() *cobra.Command { // Smoke test commands AddSmoketestCmd(rootCmd, opts) + // Resource creation commands + AddCreateCmd(rootCmd, opts) + return rootCmd } diff --git a/docs/README.md b/docs/README.md index a47a666af..c3a10cccf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ like downloading new versions. * [oms beta](oms_beta.md) - Commands for early testing * [oms build](oms_build.md) - Build and push images to a registry +* [oms create](oms_create.md) - Create resources for Codesphere * [oms download](oms_download.md) - Download resources available through OMS * [oms init](oms_init.md) - Initialize configuration files * [oms install](oms_install.md) - Install Codesphere and other components diff --git a/docs/oms.md b/docs/oms.md index a47a666af..c3a10cccf 100644 --- a/docs/oms.md +++ b/docs/oms.md @@ -19,6 +19,7 @@ like downloading new versions. * [oms beta](oms_beta.md) - Commands for early testing * [oms build](oms_build.md) - Build and push images to a registry +* [oms create](oms_create.md) - Create resources for Codesphere * [oms download](oms_download.md) - Download resources available through OMS * [oms init](oms_init.md) - Initialize configuration files * [oms install](oms_install.md) - Install Codesphere and other components diff --git a/docs/oms_beta_bootstrap-gcp.md b/docs/oms_beta_bootstrap-gcp.md index 403f52994..3ed8abb8a 100644 --- a/docs/oms_beta_bootstrap-gcp.md +++ b/docs/oms_beta_bootstrap-gcp.md @@ -19,6 +19,7 @@ oms beta bootstrap-gcp [flags] ``` --base-domain string Base domain for Codesphere (required) --billing-account string GCP Billing Account ID (required) + --create-test-user Create a test user with API token on the bootstrapped instance for smoke testing (default: false) --custom-pg-ip string Custom PostgreSQL IP (optional) --datacenter-id int Datacenter ID (default: 1) (default 1) --dns-project-id string GCP Project ID for Cloud DNS (optional) diff --git a/docs/oms_create.md b/docs/oms_create.md new file mode 100644 index 000000000..424aceb40 --- /dev/null +++ b/docs/oms_create.md @@ -0,0 +1,19 @@ +## oms create + +Create resources for Codesphere + +### Synopsis + +Create resources for Codesphere installations, such as test users for automated testing. + +### Options + +``` + -h, --help help for create +``` + +### SEE ALSO + +* [oms](oms.md) - Codesphere Operations Management System (OMS) +* [oms create test-user](oms_create_test-user.md) - Create a test user on a Codesphere database + diff --git a/docs/oms_create_test-user.md b/docs/oms_create_test-user.md new file mode 100644 index 000000000..0df82c240 --- /dev/null +++ b/docs/oms_create_test-user.md @@ -0,0 +1,34 @@ +## oms create test-user + +Create a test user on a Codesphere database + +### Synopsis + +Creates a test user with a hashed password and API token directly in a Codesphere +PostgreSQL database. The user can be used for automated smoke tests. + +The command connects to the specified PostgreSQL instance and creates the necessary +database records (credentials, email confirmation, team, team membership, API token). + +Credentials are displayed and saved to the OMS workdir as test-user.json. + +``` +oms create test-user [flags] +``` + +### Options + +``` + -h, --help help for test-user + --postgres-db string PostgreSQL database name (default "codesphere") + --postgres-host string PostgreSQL host address (required) + --postgres-password string PostgreSQL password (required) + --postgres-port int PostgreSQL port (default 5432) + --postgres-user string PostgreSQL username (default "postgres") + --ssl-mode string PostgreSQL SSL mode (default "disable") +``` + +### SEE ALSO + +* [oms create](oms_create.md) - Create resources for Codesphere + diff --git a/go.mod b/go.mod index 8506539fe..c1ed5a093 100644 --- a/go.mod +++ b/go.mod @@ -49,7 +49,11 @@ require ( sigs.k8s.io/controller-runtime v0.23.3 ) -require github.com/rook/rook/pkg/apis v0.0.0-20260326184538-170077bdfe86 +require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 + github.com/lib/pq v1.12.3 + github.com/rook/rook/pkg/apis v0.0.0-20260326184538-170077bdfe86 +) require ( 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect @@ -583,7 +587,6 @@ require ( github.com/kubernetes-csi/external-snapshotter/client/v8 v8.4.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect - github.com/lib/pq v1.12.0 // indirect github.com/libopenstorage/secrets v0.0.0-20240416031220-a17cf7f72c6c // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/mattn/go-runewidth v0.0.21 // indirect diff --git a/go.sum b/go.sum index 632aec9f7..17d333ae3 100644 --- a/go.sum +++ b/go.sum @@ -2707,6 +2707,7 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw= github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE= github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg= github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= @@ -2788,8 +2789,8 @@ github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFB github.com/letsencrypt/boulder v0.20260223.0 h1:xdS2OnJNUasR6TgVIOpqqcvdkOu47+PQQMBk9ThuWBw= github.com/letsencrypt/boulder v0.20260223.0/go.mod h1:r3aTSA7UZ7dbDfiGK+HLHJz0bWNbHk6YSPiXgzl23sA= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo= -github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/libopenstorage/autopilot-api v0.6.1-0.20210128210103-5fbb67948648/go.mod h1:6JLrPbR3ZJQFbUY/+QJMl/aF00YdIrLf8/GWAplgvJs= github.com/libopenstorage/external-storage v5.2.0+incompatible/go.mod h1:H0Gzy0h36rbJPxu3lKdrIPw1h0k0c4YFtTGGlBEBPc8= github.com/libopenstorage/openstorage v8.0.0+incompatible/go.mod h1:Sp1sIObHjat1BeXhfMqLZ14wnOzEhNx2YQedreMcUyc= diff --git a/internal/bootstrap/gcp/gcp.go b/internal/bootstrap/gcp/gcp.go index 111cd080f..98af8e5d2 100644 --- a/internal/bootstrap/gcp/gcp.go +++ b/internal/bootstrap/gcp/gcp.go @@ -848,7 +848,8 @@ func (b *GCPBootstrapper) ensureCodespherePackageOnJumpbox() (string, error) { return "", fmt.Errorf("install hash must be set when install version is set") } b.stlog.Logf("Downloading Codesphere package...") - downloadCmd := fmt.Sprintf("oms download package -f %s -H %s %s", packageFilename, b.Env.InstallHash, b.Env.InstallVersion) + downloadCmd := fmt.Sprintf("oms download package -f %s -H %s %s", + packageFilename, b.Env.InstallHash, b.Env.InstallVersion) err := b.Env.Jumpbox.RunSSHCommand("root", downloadCmd) if err != nil { return "", fmt.Errorf("failed to download Codesphere package from jumpbox: %w", err) diff --git a/internal/installer/node/node.go b/internal/installer/node/node.go index 9d2a2460d..a203c5276 100644 --- a/internal/installer/node/node.go +++ b/internal/installer/node/node.go @@ -89,6 +89,7 @@ func (r *SSHNodeClient) RunCommand(n *Node, username string, command string) err defer util.IgnoreError(session.Close) _ = session.Setenv("OMS_PORTAL_API_KEY", os.Getenv("OMS_PORTAL_API_KEY")) + _ = session.Setenv("OMS_PORTAL_API", os.Getenv("OMS_PORTAL_API")) _ = agent.RequestAgentForwarding(session) // Best effort, ignore errors if !r.Quiet { @@ -209,7 +210,7 @@ func (n *Node) InstallOms() error { // HasAcceptEnvConfigured checks if AcceptEnv is configured func (n *Node) HasAcceptEnvConfigured() bool { - checkCommand := "sudo grep -E '^AcceptEnv OMS_PORTAL_API_KEY' /etc/ssh/sshd_config >/dev/null 2>&1" + checkCommand := "sudo grep -qxF 'AcceptEnv OMS_PORTAL_API_KEY OMS_PORTAL_API' /etc/ssh/sshd_config >/dev/null 2>&1" err := n.RunSSHCommand("ubuntu", checkCommand) if err != nil { // If the command returns a NON-zero exit status, it means AcceptEnv is not configured @@ -218,10 +219,10 @@ func (n *Node) HasAcceptEnvConfigured() bool { return true } -// ConfigureAcceptEnv configures AcceptEnv for OMS_PORTAL_API_KEY +// ConfigureAcceptEnv configures AcceptEnv for OMS_PORTAL_API_KEY and OMS_PORTAL_API func (n *Node) ConfigureAcceptEnv() error { cmds := []string{ - "sudo sed -i 's/^#\\?AcceptEnv.*/AcceptEnv OMS_PORTAL_API_KEY/' /etc/ssh/sshd_config", + "sudo sh -c \"grep -qxF 'AcceptEnv OMS_PORTAL_API_KEY OMS_PORTAL_API' /etc/ssh/sshd_config || printf '\\nAcceptEnv OMS_PORTAL_API_KEY OMS_PORTAL_API\\n' >> /etc/ssh/sshd_config\"", "sudo systemctl restart sshd", } for _, cmd := range cmds { diff --git a/internal/testuser/hash.go b/internal/testuser/hash.go new file mode 100644 index 000000000..9ed76b743 --- /dev/null +++ b/internal/testuser/hash.go @@ -0,0 +1,39 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package testuser + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" +) + +// HashPassword hashes a password using Codesphere's double-SHA256 scheme with salts. +// The salts are read from environment variables SALT_1 and SALT_2. +func HashPassword(password string) (string, error) { + salt1 := os.Getenv("SALT_1") + if salt1 == "" { + return "", fmt.Errorf("SALT_1 environment variable is not set") + } + salt2 := os.Getenv("SALT_2") + if salt2 == "" { + return "", fmt.Errorf("SALT_2 environment variable is not set") + } + + hashed := hashSecret(password, salt1) + hashed = hashSecret(hashed, salt2) + return hashed, nil +} + +// HashAPIToken hashes an API token using a single SHA256 with no additional salt. +func HashAPIToken(apiToken string) string { + return hashSecret(apiToken, "") +} + +func hashSecret(secret, salt string) string { + hasher := sha256.New() + _, _ = hasher.Write([]byte(secret + salt)) + return hex.EncodeToString(hasher.Sum(nil)) +} diff --git a/internal/testuser/hash_test.go b/internal/testuser/hash_test.go new file mode 100644 index 000000000..6a7dcf803 --- /dev/null +++ b/internal/testuser/hash_test.go @@ -0,0 +1,98 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package testuser + +import ( + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("HashPassword", func() { + BeforeEach(func() { + os.Setenv("SALT_1", "testsalt1") + os.Setenv("SALT_2", "testsalt2") + }) + + AfterEach(func() { + os.Unsetenv("SALT_1") + os.Unsetenv("SALT_2") + }) + + It("produces a deterministic result", func() { + hash1, err := HashPassword("Test1234!") + Expect(err).NotTo(HaveOccurred()) + hash2, err := HashPassword("Test1234!") + Expect(err).NotTo(HaveOccurred()) + Expect(hash1).To(Equal(hash2)) + }) + + It("produces a valid 64-char hex string", func() { + hash, err := HashPassword("Test1234!") + Expect(err).NotTo(HaveOccurred()) + Expect(hash).To(HaveLen(64)) + Expect(hash).To(MatchRegexp("^[0-9a-f]{64}$")) + }) + + It("produces different hashes for different inputs", func() { + hash1, err := HashPassword("password1") + Expect(err).NotTo(HaveOccurred()) + hash2, err := HashPassword("password2") + Expect(err).NotTo(HaveOccurred()) + Expect(hash1).NotTo(Equal(hash2)) + }) + + It("returns an error when SALT_1 is not set", func() { + os.Unsetenv("SALT_1") + _, err := HashPassword("Test1234!") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("SALT_1")) + }) + + It("returns an error when SALT_2 is not set", func() { + os.Unsetenv("SALT_2") + _, err := HashPassword("Test1234!") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("SALT_2")) + }) +}) + +var _ = Describe("HashAPIToken", func() { + It("produces a deterministic result", func() { + hash1 := HashAPIToken("testtoken") + hash2 := HashAPIToken("testtoken") + Expect(hash1).To(Equal(hash2)) + }) + + It("produces a valid 64-char hex string", func() { + hash := HashAPIToken("testtoken") + Expect(hash).To(HaveLen(64)) + Expect(hash).To(MatchRegexp("^[0-9a-f]{64}$")) + }) + + It("matches the expected known test vector", func() { + // SHA256("testtoken") + hash := HashAPIToken("testtoken") + Expect(hash).To(Equal("ada63e98fe50eccb55036d88eda4b2c3709f53c2b65bc0335797067e9a2a5d8b")) + }) + + It("produces different hashes for different inputs", func() { + hash1 := HashAPIToken("token1") + hash2 := HashAPIToken("token2") + Expect(hash1).NotTo(Equal(hash2)) + }) + + It("differs from HashPassword for the same input", func() { + os.Setenv("SALT_1", "testsalt1") + os.Setenv("SALT_2", "testsalt2") + defer os.Unsetenv("SALT_1") + defer os.Unsetenv("SALT_2") + + password, err := HashPassword("testtoken") + Expect(err).NotTo(HaveOccurred()) + token := HashAPIToken("testtoken") + Expect(password).NotTo(Equal(token)) + }) +}) diff --git a/internal/testuser/testuser.go b/internal/testuser/testuser.go new file mode 100644 index 000000000..55c08d8c5 --- /dev/null +++ b/internal/testuser/testuser.go @@ -0,0 +1,264 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package testuser + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "time" + + // PostgreSQL driver + _ "github.com/lib/pq" +) + +const ( + TestEmail = "test@codesphere.com" + TestPassword = "Test1234!" + TestTeamName = "Tests" + tokenPrefix = "CS_" + + // Default connection parameters. + DefaultPort = 5432 + DefaultUser = "postgres" + DefaultDBName = "codesphere" + DefaultSSLMode = "disable" +) + +// CreateTestUserOpts contains the options for creating a test user. +type CreateTestUserOpts struct { + Host string + Port int + User string + Password string + DBName string + SSLMode string +} + +// TestUserResult contains the result of creating a test user. +type TestUserResult struct { + Email string `json:"email"` + PlaintextPassword string `json:"password"` + PlaintextAPIToken string `json:"api_token"` +} + +// CreateTestUser connects to the Codesphere postgres instance and creates a test user +// with a hashed password and API token via SQL. Returns the plaintext credentials. +func CreateTestUser(opts CreateTestUserOpts) (*TestUserResult, error) { + if opts.Port == 0 { + opts.Port = DefaultPort + } + if opts.User == "" { + opts.User = DefaultUser + } + if opts.DBName == "" { + opts.DBName = DefaultDBName + } + if opts.SSLMode == "" { + opts.SSLMode = DefaultSSLMode + } + if opts.Host == "" { + return nil, fmt.Errorf("host is required") + } + if opts.Password == "" { + return nil, fmt.Errorf("password is required") + } + + plaintextToken, err := generateAPIToken() + if err != nil { + return nil, fmt.Errorf("failed to generate API token: %w", err) + } + + hashedPassword, err := HashPassword(TestPassword) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + hashedToken := HashAPIToken(plaintextToken) + + connStr := fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s connect_timeout=10", + opts.Host, opts.Port, opts.User, opts.Password, opts.DBName, opts.SSLMode, + ) + + db, err := sql.Open("postgres", connStr) + if err != nil { + return nil, fmt.Errorf("failed to open database connection: %w", err) + } + defer func() { _ = db.Close() }() + + db.SetConnMaxLifetime(30 * time.Second) + db.SetMaxOpenConns(1) + + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("failed to connect to database at %s:%d: %w", opts.Host, opts.Port, err) + } + + log.Printf("Connected to PostgreSQL at %s:%d", opts.Host, opts.Port) + + result, err := createTestUserInDB(db, hashedPassword, hashedToken) + if err != nil { + return nil, err + } + + result.PlaintextPassword = TestPassword + result.PlaintextAPIToken = plaintextToken + + return result, nil +} + +// createTestUserInDB executes the database inserts inside a transaction. +// Separated from CreateTestUser to enable unit testing with sqlmock. +func createTestUserInDB(db *sql.DB, hashedPassword, hashedToken string) (*TestUserResult, error) { + // Check if test user already exists + var exists bool + err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM authservice.credentials WHERE email = $1)`, TestEmail).Scan(&exists) + if err != nil { + return nil, fmt.Errorf("failed to check for existing test user: %w", err) + } + if exists { + return nil, fmt.Errorf("test user %s already exists", TestEmail) + } + + tx, err := db.Begin() + if err != nil { + return nil, fmt.Errorf("failed to begin transaction: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + + // Create the user credentials + var userID int + err = tx.QueryRow(` + INSERT INTO authservice.credentials + (user_id, email, password_hash, authentication_method, signed_up, banned) + VALUES(nextval('authservice.credentials_user_id_seq'::regclass), $1, $2, 'password'::text, false, false) + RETURNING user_id`, + TestEmail, hashedPassword, + ).Scan(&userID) + if err != nil { + return nil, fmt.Errorf("failed to insert credentials: %w", err) + } + + // Create email confirmation (mark as confirmed) + emailConfirmationIDBytes := make([]byte, 16) + if _, err = rand.Read(emailConfirmationIDBytes); err != nil { + return nil, fmt.Errorf("failed to generate email confirmation id: %w", err) + } + emailConfirmationIDBytes[6] = (emailConfirmationIDBytes[6] & 0x0f) | 0x40 + emailConfirmationIDBytes[8] = (emailConfirmationIDBytes[8] & 0x3f) | 0x80 + emailConfirmationID := fmt.Sprintf( + "%x-%x-%x-%x-%x", + emailConfirmationIDBytes[0:4], + emailConfirmationIDBytes[4:6], + emailConfirmationIDBytes[6:8], + emailConfirmationIDBytes[8:10], + emailConfirmationIDBytes[10:16], + ) + + _, err = tx.Exec(` + INSERT INTO authservice.email_confirmations + (id, email, pending, created_at) + VALUES($1, $2, false, CURRENT_TIMESTAMP)`, + emailConfirmationID, TestEmail, + ) + if err != nil { + return nil, fmt.Errorf("failed to insert email confirmation: %w", err) + } + + // Create team + var teamID int + err = tx.QueryRow(` + INSERT INTO "teamService".teams + (id, "name", description, first_team, default_data_center_id, deleted, deletion_pending, created_at) + VALUES(nextval('"teamService".teams_id_seq'::regclass), $1, '', true, 1, false, false, CURRENT_TIMESTAMP) + RETURNING id`, + TestTeamName, + ).Scan(&teamID) + if err != nil { + return nil, fmt.Errorf("failed to insert team: %w", err) + } + + // Add user to team as owner (role=0) + _, err = tx.Exec(` + INSERT INTO "teamService".team_members + (user_id, team_id, "role", pending, created_at) + VALUES($1, $2, 0, false, CURRENT_TIMESTAMP)`, + userID, teamID, + ) + if err != nil { + return nil, fmt.Errorf("failed to insert team member: %w", err) + } + + // Create API token + _, err = tx.Exec(` + INSERT INTO public_api_service.tokens + (id, "token", user_id, "name", created_at) + VALUES(nextval('public_api_service.tokens_id_seq'::regclass), $1, $2, 'testkey', now())`, + hashedToken, userID, + ) + if err != nil { + return nil, fmt.Errorf("failed to insert API token: %w", err) + } + + if err = tx.Commit(); err != nil { + return nil, fmt.Errorf("failed to commit transaction: %w", err) + } + committed = true + + log.Printf("Test user created: email=%s, userID=%d, teamID=%d", TestEmail, userID, teamID) + + return &TestUserResult{ + Email: TestEmail, + }, nil +} + +// LogAndPersistResult writes the test user result to a JSON file and logs the credentials. +func LogAndPersistResult(result *TestUserResult, workdir string) { + filePath, err := WriteResultToFile(result, workdir) + if err != nil { + log.Printf("warning: failed to write test user result to file: %v", err) + } else { + log.Printf("Test user credentials written to %s", filePath) + } + + log.Printf("Email: %s", result.Email) + log.Printf("Password: %s", result.PlaintextPassword) + log.Printf("API Token: %s", result.PlaintextAPIToken) +} + +// WriteResultToFile writes the test user result to a JSON file in the given directory. +func WriteResultToFile(result *TestUserResult, dir string) (string, error) { + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal test user result: %w", err) + } + + if err := os.MkdirAll(dir, 0700); err != nil { + return "", fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + filePath := filepath.Join(dir, "test-user.json") + if err := os.WriteFile(filePath, data, 0600); err != nil { + return "", fmt.Errorf("failed to write test user file: %w", err) + } + + return filePath, nil +} + +func generateAPIToken() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return tokenPrefix + hex.EncodeToString(b), nil +} diff --git a/internal/testuser/testuser_suite_test.go b/internal/testuser/testuser_suite_test.go new file mode 100644 index 000000000..2770b685d --- /dev/null +++ b/internal/testuser/testuser_suite_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package testuser + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestTestuser(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Testuser Suite") +} diff --git a/internal/testuser/testuser_test.go b/internal/testuser/testuser_test.go new file mode 100644 index 000000000..39ac1a8e8 --- /dev/null +++ b/internal/testuser/testuser_test.go @@ -0,0 +1,220 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package testuser + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/DATA-DOG/go-sqlmock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("generateAPIToken", func() { + It("returns no error", func() { + _, err := generateAPIToken() + Expect(err).NotTo(HaveOccurred()) + }) + + It("starts with the CS_ prefix", func() { + token, err := generateAPIToken() + Expect(err).NotTo(HaveOccurred()) + Expect(token).To(HavePrefix("CS_")) + }) + + It("has the correct length (3 prefix + 32 hex chars)", func() { + token, err := generateAPIToken() + Expect(err).NotTo(HaveOccurred()) + Expect(token).To(HaveLen(35)) + }) + + It("produces unique tokens on successive calls", func() { + token1, err := generateAPIToken() + Expect(err).NotTo(HaveOccurred()) + token2, err := generateAPIToken() + Expect(err).NotTo(HaveOccurred()) + Expect(token1).NotTo(Equal(token2)) + }) + + It("contains only hex characters after the prefix", func() { + token, err := generateAPIToken() + Expect(err).NotTo(HaveOccurred()) + hexPart := token[len(tokenPrefix):] + Expect(hexPart).To(MatchRegexp("^[0-9a-f]{32}$")) + }) +}) + +var _ = Describe("WriteResultToFile", func() { + It("writes a valid JSON file to the given directory", func() { + dir := GinkgoT().TempDir() + result := &TestUserResult{ + Email: "test@example.com", + PlaintextPassword: "secret123", + PlaintextAPIToken: "CS_abc123", + } + + filePath, err := WriteResultToFile(result, dir) + Expect(err).NotTo(HaveOccurred()) + Expect(filePath).To(Equal(filepath.Join(dir, "test-user.json"))) + + data, err := os.ReadFile(filePath) + Expect(err).NotTo(HaveOccurred()) + + var loaded TestUserResult + err = json.Unmarshal(data, &loaded) + Expect(err).NotTo(HaveOccurred()) + Expect(loaded.Email).To(Equal("test@example.com")) + Expect(loaded.PlaintextPassword).To(Equal("secret123")) + Expect(loaded.PlaintextAPIToken).To(Equal("CS_abc123")) + }) + + It("creates the directory if it does not exist", func() { + dir := filepath.Join(GinkgoT().TempDir(), "nested", "subdir") + result := &TestUserResult{ + Email: "user@example.com", + PlaintextPassword: "pass", + PlaintextAPIToken: "CS_token", + } + + filePath, err := WriteResultToFile(result, dir) + Expect(err).NotTo(HaveOccurred()) + Expect(filePath).To(BeARegularFile()) + }) + + It("sets restrictive file permissions (0600)", func() { + dir := GinkgoT().TempDir() + result := &TestUserResult{ + Email: "user@example.com", + PlaintextPassword: "pass", + PlaintextAPIToken: "CS_token", + } + + filePath, err := WriteResultToFile(result, dir) + Expect(err).NotTo(HaveOccurred()) + + info, err := os.Stat(filePath) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600))) + }) +}) + +var _ = Describe("createTestUserInDB", func() { + const ( + hashedPassword = "fakehashedpassword" + hashedToken = "fakehashedtoken" + ) + + It("creates a test user successfully", func() { + sqlDB, m, err := sqlmock.New() + Expect(err).NotTo(HaveOccurred()) + defer func() { _ = sqlDB.Close() }() + + // Expect: check if user exists + m.ExpectQuery(`SELECT EXISTS`). + WithArgs(TestEmail). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + + // Expect: begin transaction + m.ExpectBegin() + + // Expect: insert credentials + m.ExpectQuery(`INSERT INTO authservice.credentials`). + WithArgs(TestEmail, hashedPassword). + WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(42)) + + // Expect: insert email confirmation + m.ExpectExec(`INSERT INTO authservice.email_confirmations`). + WithArgs(sqlmock.AnyArg(), TestEmail). + WillReturnResult(sqlmock.NewResult(1, 1)) + + // Expect: insert team + m.ExpectQuery(`INSERT INTO "teamService".teams`). + WithArgs(TestTeamName). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(7)) + + // Expect: insert team member + m.ExpectExec(`INSERT INTO "teamService".team_members`). + WithArgs(42, 7). + WillReturnResult(sqlmock.NewResult(1, 1)) + + // Expect: insert API token + m.ExpectExec(`INSERT INTO public_api_service.tokens`). + WithArgs(hashedToken, 42). + WillReturnResult(sqlmock.NewResult(1, 1)) + + // Expect: commit + m.ExpectCommit() + + result, err := createTestUserInDB(sqlDB, hashedPassword, hashedToken) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Email).To(Equal(TestEmail)) + Expect(m.ExpectationsWereMet()).NotTo(HaveOccurred()) + }) + + It("returns an error when the test user already exists", func() { + sqlDB, m, err := sqlmock.New() + Expect(err).NotTo(HaveOccurred()) + defer func() { _ = sqlDB.Close() }() + + m.ExpectQuery(`SELECT EXISTS`). + WithArgs(TestEmail). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + _, err = createTestUserInDB(sqlDB, hashedPassword, hashedToken) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("already exists")) + Expect(m.ExpectationsWereMet()).NotTo(HaveOccurred()) + }) + + It("rolls back the transaction on credential insert failure", func() { + sqlDB, m, err := sqlmock.New() + Expect(err).NotTo(HaveOccurred()) + defer func() { _ = sqlDB.Close() }() + + m.ExpectQuery(`SELECT EXISTS`). + WithArgs(TestEmail). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + + m.ExpectBegin() + m.ExpectQuery(`INSERT INTO authservice.credentials`). + WithArgs(TestEmail, hashedPassword). + WillReturnError(fmt.Errorf("unique_violation")) + m.ExpectRollback() + + _, err = createTestUserInDB(sqlDB, hashedPassword, hashedToken) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to insert credentials")) + Expect(m.ExpectationsWereMet()).NotTo(HaveOccurred()) + }) + + It("rolls back the transaction on team insert failure", func() { + sqlDB, m, err := sqlmock.New() + Expect(err).NotTo(HaveOccurred()) + defer func() { _ = sqlDB.Close() }() + + m.ExpectQuery(`SELECT EXISTS`). + WithArgs(TestEmail). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + + m.ExpectBegin() + m.ExpectQuery(`INSERT INTO authservice.credentials`). + WithArgs(TestEmail, hashedPassword). + WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(42)) + m.ExpectExec(`INSERT INTO authservice.email_confirmations`). + WithArgs(sqlmock.AnyArg(), TestEmail). + WillReturnResult(sqlmock.NewResult(1, 1)) + m.ExpectQuery(`INSERT INTO "teamService".teams`). + WithArgs(TestTeamName). + WillReturnError(fmt.Errorf("db error")) + m.ExpectRollback() + + _, err = createTestUserInDB(sqlDB, hashedPassword, hashedToken) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to insert team")) + Expect(m.ExpectationsWereMet()).NotTo(HaveOccurred()) + }) +}) diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index 53cd06a48..6b2076b0e 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -707,9 +707,9 @@ License URL: https://github.com/lann/ps/blob/62de8c46ede0/LICENSE ---------- Module: github.com/lib/pq -Version: v1.12.0 +Version: v1.12.3 License: MIT -License URL: https://github.com/lib/pq/blob/v1.12.0/LICENSE +License URL: https://github.com/lib/pq/blob/v1.12.3/LICENSE ---------- Module: github.com/libopenstorage/secrets From 99f814c36ef757cacae6f82241be3ce54ed42ff3 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:54:04 +0200 Subject: [PATCH 02/12] ref: fix linter --- cli/cmd/bootstrap_gcp.go | 27 --------------------------- internal/testuser/hash_test.go | 22 ++++++++++++---------- 2 files changed, 12 insertions(+), 37 deletions(-) diff --git a/cli/cmd/bootstrap_gcp.go b/cli/cmd/bootstrap_gcp.go index 90442cfc3..7ecc58ccc 100644 --- a/cli/cmd/bootstrap_gcp.go +++ b/cli/cmd/bootstrap_gcp.go @@ -4,7 +4,6 @@ package cmd import ( - "encoding/json" "fmt" "log" "os" @@ -193,32 +192,6 @@ func (c *BootstrapGcpCmd) BootstrapGcp() error { return nil } -// writeInfraDetails writes details about the bootstrapped codesphere environment into a file. -func writeInfraDetails(csEnv *gcp.CodesphereEnvironment) error { - envBytes, err := json.MarshalIndent(csEnv, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal codesphere env: %w", err) - } - - workdir := env.NewEnv().GetOmsWorkdir() - fw := util.NewFilesystemWriter() - - err = fw.MkdirAll(workdir, 0755) - if err != nil { - return fmt.Errorf("failed to create workdir %w", err) - } - - infraFilePath := gcp.GetInfraFilePath() - err = fw.WriteFile(infraFilePath, envBytes, 0644) - if err != nil { - return fmt.Errorf("failed to write gcp bootstrap env file: %w", err) - } - - log.Printf("Infrastructure details written to %s", infraFilePath) - - return nil -} - func (c *BootstrapGcpCmd) createTestUser(bs *gcp.GCPBootstrapper) error { if bs.Env.PostgreSQLNode == nil { return fmt.Errorf("postgres node not found in bootstrap environment") diff --git a/internal/testuser/hash_test.go b/internal/testuser/hash_test.go index 6a7dcf803..cc66a3329 100644 --- a/internal/testuser/hash_test.go +++ b/internal/testuser/hash_test.go @@ -12,13 +12,15 @@ import ( var _ = Describe("HashPassword", func() { BeforeEach(func() { - os.Setenv("SALT_1", "testsalt1") - os.Setenv("SALT_2", "testsalt2") + GinkgoHelper() + Expect(os.Setenv("SALT_1", "testsalt1")).To(Succeed()) + Expect(os.Setenv("SALT_2", "testsalt2")).To(Succeed()) }) AfterEach(func() { - os.Unsetenv("SALT_1") - os.Unsetenv("SALT_2") + GinkgoHelper() + Expect(os.Unsetenv("SALT_1")).To(Succeed()) + Expect(os.Unsetenv("SALT_2")).To(Succeed()) }) It("produces a deterministic result", func() { @@ -45,14 +47,14 @@ var _ = Describe("HashPassword", func() { }) It("returns an error when SALT_1 is not set", func() { - os.Unsetenv("SALT_1") + Expect(os.Unsetenv("SALT_1")).To(Succeed()) _, err := HashPassword("Test1234!") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("SALT_1")) }) It("returns an error when SALT_2 is not set", func() { - os.Unsetenv("SALT_2") + Expect(os.Unsetenv("SALT_2")).To(Succeed()) _, err := HashPassword("Test1234!") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("SALT_2")) @@ -85,10 +87,10 @@ var _ = Describe("HashAPIToken", func() { }) It("differs from HashPassword for the same input", func() { - os.Setenv("SALT_1", "testsalt1") - os.Setenv("SALT_2", "testsalt2") - defer os.Unsetenv("SALT_1") - defer os.Unsetenv("SALT_2") + Expect(os.Setenv("SALT_1", "testsalt1")).To(Succeed()) + Expect(os.Setenv("SALT_2", "testsalt2")).To(Succeed()) + defer func() { Expect(os.Unsetenv("SALT_1")).To(Succeed()) }() + defer func() { Expect(os.Unsetenv("SALT_2")).To(Succeed()) }() password, err := HashPassword("testtoken") Expect(err).NotTo(HaveOccurred()) From 2e0105b65313ce2b7ddf4cc2864866d5a0201cdf Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:42:33 +0200 Subject: [PATCH 03/12] ref: enhance test user creation logic and add password generation tests --- cli/cmd/bootstrap_gcp.go | 6 +- internal/testuser/testuser.go | 190 ++++++++++++++++++++--------- internal/testuser/testuser_test.go | 37 +++++- 3 files changed, 170 insertions(+), 63 deletions(-) diff --git a/cli/cmd/bootstrap_gcp.go b/cli/cmd/bootstrap_gcp.go index 7ecc58ccc..66fa89bb1 100644 --- a/cli/cmd/bootstrap_gcp.go +++ b/cli/cmd/bootstrap_gcp.go @@ -202,10 +202,10 @@ func (c *BootstrapGcpCmd) createTestUser(bs *gcp.GCPBootstrapper) error { return fmt.Errorf("postgres node has no external IP") } - pgPassword := "" - if bs.Env.InstallConfig != nil { - pgPassword = bs.Env.InstallConfig.Postgres.AdminPassword + if bs.Env.InstallConfig == nil { + return fmt.Errorf("install config not found in bootstrap environment") } + pgPassword := bs.Env.InstallConfig.Postgres.AdminPassword if pgPassword == "" { return fmt.Errorf("postgres admin password not found in install config") } diff --git a/internal/testuser/testuser.go b/internal/testuser/testuser.go index 55c08d8c5..a097f01f2 100644 --- a/internal/testuser/testuser.go +++ b/internal/testuser/testuser.go @@ -20,7 +20,6 @@ import ( const ( TestEmail = "test@codesphere.com" - TestPassword = "Test1234!" TestTeamName = "Tests" tokenPrefix = "CS_" @@ -48,9 +47,14 @@ type TestUserResult struct { PlaintextAPIToken string `json:"api_token"` } -// CreateTestUser connects to the Codesphere postgres instance and creates a test user -// with a hashed password and API token via SQL. Returns the plaintext credentials. -func CreateTestUser(opts CreateTestUserOpts) (*TestUserResult, error) { +// TestUserCreator manages the lifecycle of test user creation. +type TestUserCreator struct { + opts CreateTestUserOpts + db *sql.DB +} + +// New creates a new TestUserCreator with the given options. +func New(opts CreateTestUserOpts) (*TestUserCreator, error) { if opts.Port == 0 { opts.Port = DefaultPort } @@ -69,55 +73,93 @@ func CreateTestUser(opts CreateTestUserOpts) (*TestUserResult, error) { if opts.Password == "" { return nil, fmt.Errorf("password is required") } + return &TestUserCreator{opts: opts}, nil +} - plaintextToken, err := generateAPIToken() - if err != nil { - return nil, fmt.Errorf("failed to generate API token: %w", err) - } - - hashedPassword, err := HashPassword(TestPassword) - if err != nil { - return nil, fmt.Errorf("failed to hash password: %w", err) - } - hashedToken := HashAPIToken(plaintextToken) +// newWithDB creates a TestUserCreator with an already-opened DB. +func newWithDB(opts CreateTestUserOpts, db *sql.DB) *TestUserCreator { + return &TestUserCreator{opts: opts, db: db} +} +// Connect opens the database connection and verifies it with a ping. +func (c *TestUserCreator) Connect() error { connStr := fmt.Sprintf( "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s connect_timeout=10", - opts.Host, opts.Port, opts.User, opts.Password, opts.DBName, opts.SSLMode, + c.opts.Host, c.opts.Port, c.opts.User, c.opts.Password, c.opts.DBName, c.opts.SSLMode, ) db, err := sql.Open("postgres", connStr) if err != nil { - return nil, fmt.Errorf("failed to open database connection: %w", err) + return fmt.Errorf("failed to open database connection: %w", err) } - defer func() { _ = db.Close() }() db.SetConnMaxLifetime(30 * time.Second) db.SetMaxOpenConns(1) if err := db.Ping(); err != nil { - return nil, fmt.Errorf("failed to connect to database at %s:%d: %w", opts.Host, opts.Port, err) + _ = db.Close() + return fmt.Errorf("failed to connect to database at %s:%d: %w", c.opts.Host, c.opts.Port, err) } - log.Printf("Connected to PostgreSQL at %s:%d", opts.Host, opts.Port) + log.Printf("Connected to PostgreSQL at %s:%d", c.opts.Host, c.opts.Port) + c.db = db + return nil +} + +// close closes the underlying database connection. +func (c *TestUserCreator) close() error { + if c.db != nil { + return c.db.Close() + } + return nil +} - result, err := createTestUserInDB(db, hashedPassword, hashedToken) +// Create generates credentials and inserts the test user into the database. +func (c *TestUserCreator) Create() (*TestUserResult, error) { + plaintextPassword, err := generatePassword() + if err != nil { + return nil, fmt.Errorf("failed to generate password: %w", err) + } + + plaintextToken, err := generateAPIToken() + if err != nil { + return nil, fmt.Errorf("failed to generate API token: %w", err) + } + + hashedPassword, err := HashPassword(plaintextPassword) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + hashedToken := HashAPIToken(plaintextToken) + + result, err := c.createInDB(hashedPassword, hashedToken) if err != nil { return nil, err } - result.PlaintextPassword = TestPassword + result.PlaintextPassword = plaintextPassword result.PlaintextAPIToken = plaintextToken return result, nil } -// createTestUserInDB executes the database inserts inside a transaction. -// Separated from CreateTestUser to enable unit testing with sqlmock. -func createTestUserInDB(db *sql.DB, hashedPassword, hashedToken string) (*TestUserResult, error) { - // Check if test user already exists +// CreateTestUser is a convenience facade: New -> Connect -> Create -> close. +func CreateTestUser(opts CreateTestUserOpts) (*TestUserResult, error) { + creator, err := New(opts) + if err != nil { + return nil, err + } + if err := creator.Connect(); err != nil { + return nil, err + } + defer func() { _ = creator.close() }() + return creator.Create() +} + +// createInDB executes the database inserts inside a transaction. +func (c *TestUserCreator) createInDB(hashedPassword, hashedToken string) (*TestUserResult, error) { var exists bool - err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM authservice.credentials WHERE email = $1)`, TestEmail).Scan(&exists) + err := c.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM authservice.credentials WHERE email = $1)`, TestEmail).Scan(&exists) if err != nil { return nil, fmt.Errorf("failed to check for existing test user: %w", err) } @@ -125,7 +167,7 @@ func createTestUserInDB(db *sql.DB, hashedPassword, hashedToken string) (*TestUs return nil, fmt.Errorf("test user %s already exists", TestEmail) } - tx, err := db.Begin() + tx, err := c.db.Begin() if err != nil { return nil, fmt.Errorf("failed to begin transaction: %w", err) } @@ -136,9 +178,41 @@ func createTestUserInDB(db *sql.DB, hashedPassword, hashedToken string) (*TestUs } }() - // Create the user credentials + userID, err := c.insertCredentials(tx, hashedPassword) + if err != nil { + return nil, err + } + + if err := c.insertEmailConfirmation(tx); err != nil { + return nil, err + } + + teamID, err := c.insertTeam(tx) + if err != nil { + return nil, err + } + + if err := c.insertTeamMember(tx, userID, teamID); err != nil { + return nil, err + } + + if err := c.insertAPIToken(tx, hashedToken, userID); err != nil { + return nil, err + } + + if err = tx.Commit(); err != nil { + return nil, fmt.Errorf("failed to commit transaction: %w", err) + } + committed = true + + log.Printf("Test user created: email=%s, userID=%d, teamID=%d", TestEmail, userID, teamID) + + return &TestUserResult{Email: TestEmail}, nil +} + +func (c *TestUserCreator) insertCredentials(tx *sql.Tx, hashedPassword string) (int, error) { var userID int - err = tx.QueryRow(` + err := tx.QueryRow(` INSERT INTO authservice.credentials (user_id, email, password_hash, authentication_method, signed_up, banned) VALUES(nextval('authservice.credentials_user_id_seq'::regclass), $1, $2, 'password'::text, false, false) @@ -146,13 +220,15 @@ func createTestUserInDB(db *sql.DB, hashedPassword, hashedToken string) (*TestUs TestEmail, hashedPassword, ).Scan(&userID) if err != nil { - return nil, fmt.Errorf("failed to insert credentials: %w", err) + return 0, fmt.Errorf("failed to insert credentials: %w", err) } + return userID, nil +} - // Create email confirmation (mark as confirmed) +func (c *TestUserCreator) insertEmailConfirmation(tx *sql.Tx) error { emailConfirmationIDBytes := make([]byte, 16) - if _, err = rand.Read(emailConfirmationIDBytes); err != nil { - return nil, fmt.Errorf("failed to generate email confirmation id: %w", err) + if _, err := rand.Read(emailConfirmationIDBytes); err != nil { + return fmt.Errorf("failed to generate email confirmation id: %w", err) } emailConfirmationIDBytes[6] = (emailConfirmationIDBytes[6] & 0x0f) | 0x40 emailConfirmationIDBytes[8] = (emailConfirmationIDBytes[8] & 0x3f) | 0x80 @@ -165,19 +241,21 @@ func createTestUserInDB(db *sql.DB, hashedPassword, hashedToken string) (*TestUs emailConfirmationIDBytes[10:16], ) - _, err = tx.Exec(` + _, err := tx.Exec(` INSERT INTO authservice.email_confirmations (id, email, pending, created_at) VALUES($1, $2, false, CURRENT_TIMESTAMP)`, emailConfirmationID, TestEmail, ) if err != nil { - return nil, fmt.Errorf("failed to insert email confirmation: %w", err) + return fmt.Errorf("failed to insert email confirmation: %w", err) } + return nil +} - // Create team +func (c *TestUserCreator) insertTeam(tx *sql.Tx) (int, error) { var teamID int - err = tx.QueryRow(` + err := tx.QueryRow(` INSERT INTO "teamService".teams (id, "name", description, first_team, default_data_center_id, deleted, deletion_pending, created_at) VALUES(nextval('"teamService".teams_id_seq'::regclass), $1, '', true, 1, false, false, CURRENT_TIMESTAMP) @@ -185,41 +263,35 @@ func createTestUserInDB(db *sql.DB, hashedPassword, hashedToken string) (*TestUs TestTeamName, ).Scan(&teamID) if err != nil { - return nil, fmt.Errorf("failed to insert team: %w", err) + return 0, fmt.Errorf("failed to insert team: %w", err) } + return teamID, nil +} - // Add user to team as owner (role=0) - _, err = tx.Exec(` +func (c *TestUserCreator) insertTeamMember(tx *sql.Tx, userID, teamID int) error { + _, err := tx.Exec(` INSERT INTO "teamService".team_members (user_id, team_id, "role", pending, created_at) VALUES($1, $2, 0, false, CURRENT_TIMESTAMP)`, userID, teamID, ) if err != nil { - return nil, fmt.Errorf("failed to insert team member: %w", err) + return fmt.Errorf("failed to insert team member: %w", err) } + return nil +} - // Create API token - _, err = tx.Exec(` +func (c *TestUserCreator) insertAPIToken(tx *sql.Tx, hashedToken string, userID int) error { + _, err := tx.Exec(` INSERT INTO public_api_service.tokens (id, "token", user_id, "name", created_at) VALUES(nextval('public_api_service.tokens_id_seq'::regclass), $1, $2, 'testkey', now())`, hashedToken, userID, ) if err != nil { - return nil, fmt.Errorf("failed to insert API token: %w", err) - } - - if err = tx.Commit(); err != nil { - return nil, fmt.Errorf("failed to commit transaction: %w", err) + return fmt.Errorf("failed to insert API token: %w", err) } - committed = true - - log.Printf("Test user created: email=%s, userID=%d, teamID=%d", TestEmail, userID, teamID) - - return &TestUserResult{ - Email: TestEmail, - }, nil + return nil } // LogAndPersistResult writes the test user result to a JSON file and logs the credentials. @@ -262,3 +334,11 @@ func generateAPIToken() (string, error) { } return tokenPrefix + hex.EncodeToString(b), nil } + +func generatePassword() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + return hex.EncodeToString(b), nil +} diff --git a/internal/testuser/testuser_test.go b/internal/testuser/testuser_test.go index 39ac1a8e8..c07388036 100644 --- a/internal/testuser/testuser_test.go +++ b/internal/testuser/testuser_test.go @@ -102,7 +102,34 @@ var _ = Describe("WriteResultToFile", func() { }) }) -var _ = Describe("createTestUserInDB", func() { +var _ = Describe("generatePassword", func() { + It("returns no error", func() { + _, err := generatePassword() + Expect(err).NotTo(HaveOccurred()) + }) + + It("has the correct length (32 hex chars from 16 bytes)", func() { + password, err := generatePassword() + Expect(err).NotTo(HaveOccurred()) + Expect(password).To(HaveLen(32)) + }) + + It("produces unique passwords on successive calls", func() { + pw1, err := generatePassword() + Expect(err).NotTo(HaveOccurred()) + pw2, err := generatePassword() + Expect(err).NotTo(HaveOccurred()) + Expect(pw1).NotTo(Equal(pw2)) + }) + + It("contains only hex characters", func() { + password, err := generatePassword() + Expect(err).NotTo(HaveOccurred()) + Expect(password).To(MatchRegexp("^[0-9a-f]{32}$")) + }) +}) + +var _ = Describe("createInDB", func() { const ( hashedPassword = "fakehashedpassword" hashedToken = "fakehashedtoken" @@ -149,7 +176,7 @@ var _ = Describe("createTestUserInDB", func() { // Expect: commit m.ExpectCommit() - result, err := createTestUserInDB(sqlDB, hashedPassword, hashedToken) + result, err := newWithDB(CreateTestUserOpts{Host: "test", Password: "test"}, sqlDB).createInDB(hashedPassword, hashedToken) Expect(err).NotTo(HaveOccurred()) Expect(result.Email).To(Equal(TestEmail)) Expect(m.ExpectationsWereMet()).NotTo(HaveOccurred()) @@ -164,7 +191,7 @@ var _ = Describe("createTestUserInDB", func() { WithArgs(TestEmail). WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) - _, err = createTestUserInDB(sqlDB, hashedPassword, hashedToken) + _, err = newWithDB(CreateTestUserOpts{Host: "test", Password: "test"}, sqlDB).createInDB(hashedPassword, hashedToken) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("already exists")) Expect(m.ExpectationsWereMet()).NotTo(HaveOccurred()) @@ -185,7 +212,7 @@ var _ = Describe("createTestUserInDB", func() { WillReturnError(fmt.Errorf("unique_violation")) m.ExpectRollback() - _, err = createTestUserInDB(sqlDB, hashedPassword, hashedToken) + _, err = newWithDB(CreateTestUserOpts{Host: "test", Password: "test"}, sqlDB).createInDB(hashedPassword, hashedToken) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to insert credentials")) Expect(m.ExpectationsWereMet()).NotTo(HaveOccurred()) @@ -212,7 +239,7 @@ var _ = Describe("createTestUserInDB", func() { WillReturnError(fmt.Errorf("db error")) m.ExpectRollback() - _, err = createTestUserInDB(sqlDB, hashedPassword, hashedToken) + _, err = newWithDB(CreateTestUserOpts{Host: "test", Password: "test"}, sqlDB).createInDB(hashedPassword, hashedToken) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to insert team")) Expect(m.ExpectationsWereMet()).NotTo(HaveOccurred()) From e26e4876f0f017dac16348081b2eaa78f9542b67 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:57:34 +0000 Subject: [PATCH 04/12] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- NOTICE | 4 ++-- internal/tmpl/NOTICE | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/NOTICE b/NOTICE index 50cd1a6d4..fe369ccb2 100644 --- a/NOTICE +++ b/NOTICE @@ -905,9 +905,9 @@ License URL: https://github.com/prometheus/procfs/blob/v0.20.1/LICENSE ---------- Module: github.com/rook/rook/pkg/apis/ceph.rook.io -Version: v0.0.0-20260417181530-5bd05dcbd0db +Version: v0.0.0-20260421122039-59ce48ae88e5 License: Apache-2.0 -License URL: https://github.com/rook/rook/blob/5bd05dcbd0db/pkg/apis/LICENSE +License URL: https://github.com/rook/rook/blob/59ce48ae88e5/pkg/apis/LICENSE ---------- Module: github.com/rubenv/sql-migrate diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index 50cd1a6d4..fe369ccb2 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -905,9 +905,9 @@ License URL: https://github.com/prometheus/procfs/blob/v0.20.1/LICENSE ---------- Module: github.com/rook/rook/pkg/apis/ceph.rook.io -Version: v0.0.0-20260417181530-5bd05dcbd0db +Version: v0.0.0-20260421122039-59ce48ae88e5 License: Apache-2.0 -License URL: https://github.com/rook/rook/blob/5bd05dcbd0db/pkg/apis/LICENSE +License URL: https://github.com/rook/rook/blob/59ce48ae88e5/pkg/apis/LICENSE ---------- Module: github.com/rubenv/sql-migrate From 11b59828be54cb332b8c15d6ddb752ed1c637c73 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:45:12 +0200 Subject: [PATCH 05/12] ref: streamline test user creation and update environment variable references --- cli/cmd/bootstrap_gcp.go | 46 +------------------ internal/bootstrap/gcp/gcp.go | 47 +++++++++++++++++++- internal/testuser/hash.go | 10 ++--- internal/testuser/hash_test.go | 24 +++++----- internal/testuser/testuser.go | 71 +++++++++++------------------- internal/testuser/testuser_test.go | 4 +- 6 files changed, 92 insertions(+), 110 deletions(-) diff --git a/cli/cmd/bootstrap_gcp.go b/cli/cmd/bootstrap_gcp.go index 66fa89bb1..61bd1e6af 100644 --- a/cli/cmd/bootstrap_gcp.go +++ b/cli/cmd/bootstrap_gcp.go @@ -18,7 +18,6 @@ import ( "github.com/codesphere-cloud/oms/internal/installer" "github.com/codesphere-cloud/oms/internal/installer/node" "github.com/codesphere-cloud/oms/internal/portal" - "github.com/codesphere-cloud/oms/internal/testuser" "github.com/codesphere-cloud/oms/internal/util" ) @@ -30,7 +29,6 @@ type BootstrapGcpCmd struct { InputRegistryType string SSHQuiet bool FeatureFlagList []string - CreateTestUser bool } func (c *BootstrapGcpCmd) RunE(_ *cobra.Command, args []string) error { @@ -96,7 +94,7 @@ func AddBootstrapGcpCmd(parent *cobra.Command, opts *GlobalOptions) { flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.WriteConfig, "write-config", true, "Write generated install config to file (default: true)") flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.RecoverConfig, "recover-config", false, "Recover previously generated install config from the jumpbox. This will overwrite the local config! (default: false)") flags.BoolVar(&bootstrapGcpCmd.SSHQuiet, "ssh-quiet", false, "Suppress SSH command output (default: false)") - flags.BoolVar(&bootstrapGcpCmd.CreateTestUser, "create-test-user", false, "Create a test user with API token on the bootstrapped instance for smoke testing (default: false)") + flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.CreateTestUser, "create-test-user", false, "Create a test user with API token on the bootstrapped instance for smoke testing (default: false)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.OpenBaoURI, "openbao-uri", "", "URI for OpenBao (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.OpenBaoEngine, "openbao-engine", "cs-secrets-engine", "OpenBao engine name (default: cs-secrets-engine)") @@ -140,6 +138,7 @@ func (c *BootstrapGcpCmd) BootstrapGcp() error { } c.CodesphereEnv.RegistryType = gcp.RegistryType(c.InputRegistryType) + c.CodesphereEnv.OmsWorkdir = c.Env.GetOmsWorkdir() if c.CodesphereEnv.GitHubPAT != "" { c.CodesphereEnv.RegistryType = gcp.RegistryTypeGitHub if c.CodesphereEnv.RegistryUser == "" { @@ -168,12 +167,6 @@ func (c *BootstrapGcpCmd) BootstrapGcp() error { log.Println("\nšŸŽ‰šŸŽ‰šŸŽ‰ GCP infrastructure bootstrapped successfully!") log.Printf("Access the jumpbox using:\nssh-add $SSH_KEY_PATH; ssh -o StrictHostKeyChecking=no -o ForwardAgent=yes -o SendEnv=OMS_PORTAL_API_KEY -o SendEnv=OMS_PORTAL_API root@%s", bs.Env.Jumpbox.GetExternalIP()) - if c.CreateTestUser { - if err := c.createTestUser(bs); err != nil { - log.Printf("warning: failed to create test user: %v", err) - } - } - if bs.Env.InstallVersion != "" { log.Printf("Access Codesphere in your web browser at https://cs.%s", bs.Env.BaseDomain) @@ -191,38 +184,3 @@ func (c *BootstrapGcpCmd) BootstrapGcp() error { return nil } - -func (c *BootstrapGcpCmd) createTestUser(bs *gcp.GCPBootstrapper) error { - if bs.Env.PostgreSQLNode == nil { - return fmt.Errorf("postgres node not found in bootstrap environment") - } - - pgHost := bs.Env.PostgreSQLNode.GetExternalIP() - if pgHost == "" { - return fmt.Errorf("postgres node has no external IP") - } - - if bs.Env.InstallConfig == nil { - return fmt.Errorf("install config not found in bootstrap environment") - } - pgPassword := bs.Env.InstallConfig.Postgres.AdminPassword - if pgPassword == "" { - return fmt.Errorf("postgres admin password not found in install config") - } - - result, err := testuser.CreateTestUser(testuser.CreateTestUserOpts{ - Host: pgHost, - Port: testuser.DefaultPort, - User: testuser.DefaultUser, - Password: pgPassword, - DBName: testuser.DefaultDBName, - SSLMode: "require", - }) - if err != nil { - return err - } - - testuser.LogAndPersistResult(result, c.Env.GetOmsWorkdir()) - - return nil -} diff --git a/internal/bootstrap/gcp/gcp.go b/internal/bootstrap/gcp/gcp.go index 6d903ec62..34884c511 100644 --- a/internal/bootstrap/gcp/gcp.go +++ b/internal/bootstrap/gcp/gcp.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "log" "slices" "strings" "time" @@ -19,6 +20,7 @@ import ( "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/installer/node" "github.com/codesphere-cloud/oms/internal/portal" + "github.com/codesphere-cloud/oms/internal/testuser" "github.com/codesphere-cloud/oms/internal/util" "github.com/lithammer/shortuuid" "google.golang.org/api/dns/v1" @@ -140,6 +142,10 @@ type CodesphereEnvironment struct { Region string `json:"region"` Zone string `json:"zone"` DNSZoneName string `json:"dns_zone_name"` + + // Test user creation + CreateTestUser bool `json:"-"` + OmsWorkdir string `json:"-"` } func NewGCPBootstrapper( @@ -310,10 +316,49 @@ func (b *GCPBootstrapper) Bootstrap() error { } } + if b.Env.CreateTestUser { + if err := b.createTestUser(); err != nil { + log.Printf("warning: failed to create test user: %v", err) + } + } + return nil } -// ValidateInput checks that the required input parameters are set and valid +// createTestUser creates a test user in the PostgreSQL instance using the testuser package and logs the credentials. +func (b *GCPBootstrapper) createTestUser() error { + if b.Env.PostgreSQLNode == nil { + return fmt.Errorf("postgres node not found in bootstrap environment") + } + + pgHost := b.Env.PostgreSQLNode.GetExternalIP() + if pgHost == "" { + return fmt.Errorf("postgres node has no external IP") + } + + if b.Env.InstallConfig == nil { + return fmt.Errorf("install config not found in bootstrap environment") + } + pgPassword := b.Env.InstallConfig.Postgres.AdminPassword + if pgPassword == "" { + return fmt.Errorf("postgres admin password not found in install config") + } + + result, err := testuser.CreateTestUser(testuser.CreateTestUserOpts{ + Host: pgHost, + Port: testuser.DefaultPort, + User: testuser.DefaultUser, + Password: pgPassword, + DBName: testuser.DefaultDBName, + SSLMode: "require", + }) + if err != nil { + return err + } + + testuser.LogAndPersistResult(result, b.Env.OmsWorkdir) + return nil +} func (b *GCPBootstrapper) ValidateInput() error { err := b.validateInstallVersion() if err != nil { diff --git a/internal/testuser/hash.go b/internal/testuser/hash.go index 9ed76b743..1a5cd6a4a 100644 --- a/internal/testuser/hash.go +++ b/internal/testuser/hash.go @@ -11,15 +11,15 @@ import ( ) // HashPassword hashes a password using Codesphere's double-SHA256 scheme with salts. -// The salts are read from environment variables SALT_1 and SALT_2. +// The salts are read from environment variables OMS_CS_SALT_1 and OMS_CS_SALT_2. func HashPassword(password string) (string, error) { - salt1 := os.Getenv("SALT_1") + salt1 := os.Getenv("OMS_CS_SALT_1") if salt1 == "" { - return "", fmt.Errorf("SALT_1 environment variable is not set") + return "", fmt.Errorf("OMS_CS_SALT_1 environment variable is not set") } - salt2 := os.Getenv("SALT_2") + salt2 := os.Getenv("OMS_CS_SALT_2") if salt2 == "" { - return "", fmt.Errorf("SALT_2 environment variable is not set") + return "", fmt.Errorf("OMS_CS_SALT_2 environment variable is not set") } hashed := hashSecret(password, salt1) diff --git a/internal/testuser/hash_test.go b/internal/testuser/hash_test.go index cc66a3329..a1913881b 100644 --- a/internal/testuser/hash_test.go +++ b/internal/testuser/hash_test.go @@ -13,14 +13,14 @@ import ( var _ = Describe("HashPassword", func() { BeforeEach(func() { GinkgoHelper() - Expect(os.Setenv("SALT_1", "testsalt1")).To(Succeed()) - Expect(os.Setenv("SALT_2", "testsalt2")).To(Succeed()) + Expect(os.Setenv("OMS_CS_SALT_1", "testsalt1")).To(Succeed()) + Expect(os.Setenv("OMS_CS_SALT_2", "testsalt2")).To(Succeed()) }) AfterEach(func() { GinkgoHelper() - Expect(os.Unsetenv("SALT_1")).To(Succeed()) - Expect(os.Unsetenv("SALT_2")).To(Succeed()) + Expect(os.Unsetenv("OMS_CS_SALT_1")).To(Succeed()) + Expect(os.Unsetenv("OMS_CS_SALT_2")).To(Succeed()) }) It("produces a deterministic result", func() { @@ -47,17 +47,17 @@ var _ = Describe("HashPassword", func() { }) It("returns an error when SALT_1 is not set", func() { - Expect(os.Unsetenv("SALT_1")).To(Succeed()) + Expect(os.Unsetenv("OMS_CS_SALT_1")).To(Succeed()) _, err := HashPassword("Test1234!") Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("SALT_1")) + Expect(err.Error()).To(ContainSubstring("OMS_CS_SALT_1")) }) It("returns an error when SALT_2 is not set", func() { - Expect(os.Unsetenv("SALT_2")).To(Succeed()) + Expect(os.Unsetenv("OMS_CS_SALT_2")).To(Succeed()) _, err := HashPassword("Test1234!") Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("SALT_2")) + Expect(err.Error()).To(ContainSubstring("OMS_CS_SALT_2")) }) }) @@ -87,10 +87,10 @@ var _ = Describe("HashAPIToken", func() { }) It("differs from HashPassword for the same input", func() { - Expect(os.Setenv("SALT_1", "testsalt1")).To(Succeed()) - Expect(os.Setenv("SALT_2", "testsalt2")).To(Succeed()) - defer func() { Expect(os.Unsetenv("SALT_1")).To(Succeed()) }() - defer func() { Expect(os.Unsetenv("SALT_2")).To(Succeed()) }() + Expect(os.Setenv("OMS_CS_SALT_1", "testsalt1")).To(Succeed()) + Expect(os.Setenv("OMS_CS_SALT_2", "testsalt2")).To(Succeed()) + defer func() { Expect(os.Unsetenv("OMS_CS_SALT_1")).To(Succeed()) }() + defer func() { Expect(os.Unsetenv("OMS_CS_SALT_2")).To(Succeed()) }() password, err := HashPassword("testtoken") Expect(err).NotTo(HaveOccurred()) diff --git a/internal/testuser/testuser.go b/internal/testuser/testuser.go index a097f01f2..bb01580ed 100644 --- a/internal/testuser/testuser.go +++ b/internal/testuser/testuser.go @@ -73,24 +73,15 @@ func New(opts CreateTestUserOpts) (*TestUserCreator, error) { if opts.Password == "" { return nil, fmt.Errorf("password is required") } - return &TestUserCreator{opts: opts}, nil -} -// newWithDB creates a TestUserCreator with an already-opened DB. -func newWithDB(opts CreateTestUserOpts, db *sql.DB) *TestUserCreator { - return &TestUserCreator{opts: opts, db: db} -} - -// Connect opens the database connection and verifies it with a ping. -func (c *TestUserCreator) Connect() error { connStr := fmt.Sprintf( "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s connect_timeout=10", - c.opts.Host, c.opts.Port, c.opts.User, c.opts.Password, c.opts.DBName, c.opts.SSLMode, + opts.Host, opts.Port, opts.User, opts.Password, opts.DBName, opts.SSLMode, ) db, err := sql.Open("postgres", connStr) if err != nil { - return fmt.Errorf("failed to open database connection: %w", err) + return nil, fmt.Errorf("failed to open database connection: %w", err) } db.SetConnMaxLifetime(30 * time.Second) @@ -98,12 +89,16 @@ func (c *TestUserCreator) Connect() error { if err := db.Ping(); err != nil { _ = db.Close() - return fmt.Errorf("failed to connect to database at %s:%d: %w", c.opts.Host, c.opts.Port, err) + return nil, fmt.Errorf("failed to connect to database at %s:%d: %w", opts.Host, opts.Port, err) } - log.Printf("Connected to PostgreSQL at %s:%d", c.opts.Host, c.opts.Port) - c.db = db - return nil + log.Printf("Connected to PostgreSQL at %s:%d", opts.Host, opts.Port) + return &TestUserCreator{opts: opts, db: db}, nil +} + +// newWithDB creates a TestUserCreator with an already-opened DB. +func newWithDB(opts CreateTestUserOpts, db *sql.DB) *TestUserCreator { + return &TestUserCreator{opts: opts, db: db} } // close closes the underlying database connection. @@ -143,25 +138,21 @@ func (c *TestUserCreator) Create() (*TestUserResult, error) { return result, nil } -// CreateTestUser is a convenience facade: New -> Connect -> Create -> close. +// CreateTestUser is a convenience facade: New -> Create -> close. func CreateTestUser(opts CreateTestUserOpts) (*TestUserResult, error) { creator, err := New(opts) if err != nil { return nil, err } - if err := creator.Connect(); err != nil { - return nil, err - } defer func() { _ = creator.close() }() return creator.Create() } // createInDB executes the database inserts inside a transaction. func (c *TestUserCreator) createInDB(hashedPassword, hashedToken string) (*TestUserResult, error) { - var exists bool - err := c.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM authservice.credentials WHERE email = $1)`, TestEmail).Scan(&exists) + exists, err := c.userExists() if err != nil { - return nil, fmt.Errorf("failed to check for existing test user: %w", err) + return nil, err } if exists { return nil, fmt.Errorf("test user %s already exists", TestEmail) @@ -171,12 +162,7 @@ func (c *TestUserCreator) createInDB(hashedPassword, hashedToken string) (*TestU if err != nil { return nil, fmt.Errorf("failed to begin transaction: %w", err) } - committed := false - defer func() { - if !committed { - _ = tx.Rollback() - } - }() + defer func() { _ = tx.Rollback() }() userID, err := c.insertCredentials(tx, hashedPassword) if err != nil { @@ -203,13 +189,21 @@ func (c *TestUserCreator) createInDB(hashedPassword, hashedToken string) (*TestU if err = tx.Commit(); err != nil { return nil, fmt.Errorf("failed to commit transaction: %w", err) } - committed = true log.Printf("Test user created: email=%s, userID=%d, teamID=%d", TestEmail, userID, teamID) return &TestUserResult{Email: TestEmail}, nil } +func (c *TestUserCreator) userExists() (bool, error) { + var exists bool + err := c.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM authservice.credentials WHERE email = $1)`, TestEmail).Scan(&exists) + if err != nil { + return false, fmt.Errorf("failed to check for existing test user: %w", err) + } + return exists, nil +} + func (c *TestUserCreator) insertCredentials(tx *sql.Tx, hashedPassword string) (int, error) { var userID int err := tx.QueryRow(` @@ -226,26 +220,11 @@ func (c *TestUserCreator) insertCredentials(tx *sql.Tx, hashedPassword string) ( } func (c *TestUserCreator) insertEmailConfirmation(tx *sql.Tx) error { - emailConfirmationIDBytes := make([]byte, 16) - if _, err := rand.Read(emailConfirmationIDBytes); err != nil { - return fmt.Errorf("failed to generate email confirmation id: %w", err) - } - emailConfirmationIDBytes[6] = (emailConfirmationIDBytes[6] & 0x0f) | 0x40 - emailConfirmationIDBytes[8] = (emailConfirmationIDBytes[8] & 0x3f) | 0x80 - emailConfirmationID := fmt.Sprintf( - "%x-%x-%x-%x-%x", - emailConfirmationIDBytes[0:4], - emailConfirmationIDBytes[4:6], - emailConfirmationIDBytes[6:8], - emailConfirmationIDBytes[8:10], - emailConfirmationIDBytes[10:16], - ) - _, err := tx.Exec(` INSERT INTO authservice.email_confirmations (id, email, pending, created_at) - VALUES($1, $2, false, CURRENT_TIMESTAMP)`, - emailConfirmationID, TestEmail, + VALUES(uuid_generate_v4(), $1, false, CURRENT_TIMESTAMP)`, + TestEmail, ) if err != nil { return fmt.Errorf("failed to insert email confirmation: %w", err) diff --git a/internal/testuser/testuser_test.go b/internal/testuser/testuser_test.go index c07388036..bfa5c5095 100644 --- a/internal/testuser/testuser_test.go +++ b/internal/testuser/testuser_test.go @@ -155,7 +155,7 @@ var _ = Describe("createInDB", func() { // Expect: insert email confirmation m.ExpectExec(`INSERT INTO authservice.email_confirmations`). - WithArgs(sqlmock.AnyArg(), TestEmail). + WithArgs(TestEmail). WillReturnResult(sqlmock.NewResult(1, 1)) // Expect: insert team @@ -232,7 +232,7 @@ var _ = Describe("createInDB", func() { WithArgs(TestEmail, hashedPassword). WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(42)) m.ExpectExec(`INSERT INTO authservice.email_confirmations`). - WithArgs(sqlmock.AnyArg(), TestEmail). + WithArgs(TestEmail). WillReturnResult(sqlmock.NewResult(1, 1)) m.ExpectQuery(`INSERT INTO "teamService".teams`). WithArgs(TestTeamName). From dc580caa4723d5661d9eb82af943b94a0b1a72ac Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:50:01 +0000 Subject: [PATCH 06/12] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- NOTICE | 4 ++-- internal/tmpl/NOTICE | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/NOTICE b/NOTICE index fe369ccb2..e5009b9a6 100644 --- a/NOTICE +++ b/NOTICE @@ -743,9 +743,9 @@ License URL: https://github.com/mattn/go-isatty/blob/v0.0.20/LICENSE ---------- Module: github.com/mattn/go-runewidth -Version: v0.0.21 +Version: v0.0.23 License: MIT -License URL: https://github.com/mattn/go-runewidth/blob/v0.0.21/LICENSE +License URL: https://github.com/mattn/go-runewidth/blob/v0.0.23/LICENSE ---------- Module: github.com/mitchellh/copystructure diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index fe369ccb2..e5009b9a6 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -743,9 +743,9 @@ License URL: https://github.com/mattn/go-isatty/blob/v0.0.20/LICENSE ---------- Module: github.com/mattn/go-runewidth -Version: v0.0.21 +Version: v0.0.23 License: MIT -License URL: https://github.com/mattn/go-runewidth/blob/v0.0.21/LICENSE +License URL: https://github.com/mattn/go-runewidth/blob/v0.0.23/LICENSE ---------- Module: github.com/mitchellh/copystructure From 87808bdb8a625178a401f3fa357a0efaa8b95a56 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:14:19 +0200 Subject: [PATCH 07/12] ref: enhance error handling in RunCommand for quiet mode --- internal/installer/node/node.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/installer/node/node.go b/internal/installer/node/node.go index a203c5276..29f8ba6a1 100644 --- a/internal/installer/node/node.go +++ b/internal/installer/node/node.go @@ -4,6 +4,7 @@ package node import ( + "bytes" "fmt" "log" "net" @@ -92,9 +93,12 @@ func (r *SSHNodeClient) RunCommand(n *Node, username string, command string) err _ = session.Setenv("OMS_PORTAL_API", os.Getenv("OMS_PORTAL_API")) _ = agent.RequestAgentForwarding(session) // Best effort, ignore errors + var stderrBuf bytes.Buffer if !r.Quiet { session.Stdout = os.Stdout session.Stderr = os.Stderr + } else { + session.Stderr = &stderrBuf } // Start the command if err := session.Start(command); err != nil { @@ -103,6 +107,9 @@ func (r *SSHNodeClient) RunCommand(n *Node, username string, command string) err if err := session.Wait(); err != nil { // A non-zero exit status from the remote command is also considered an error + if r.Quiet && stderrBuf.Len() > 0 { + return fmt.Errorf("command failed: %w\n%s", err, stderrBuf.String()) + } return fmt.Errorf("command failed: %w", err) } return nil From d8e2d7190702f257e799a448bf71f5bae8689640 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:03:44 +0200 Subject: [PATCH 08/12] ref: streamline test user creation and remove unused password hashing logic --- internal/installer/node/node.go | 3 +- internal/testuser/hash.go | 19 --------- internal/testuser/hash_test.go | 65 ------------------------------ internal/testuser/testuser.go | 27 ++++--------- internal/testuser/testuser_test.go | 35 ++-------------- 5 files changed, 12 insertions(+), 137 deletions(-) diff --git a/internal/installer/node/node.go b/internal/installer/node/node.go index 29f8ba6a1..931849fca 100644 --- a/internal/installer/node/node.go +++ b/internal/installer/node/node.go @@ -94,11 +94,10 @@ func (r *SSHNodeClient) RunCommand(n *Node, username string, command string) err _ = agent.RequestAgentForwarding(session) // Best effort, ignore errors var stderrBuf bytes.Buffer + session.Stderr = &stderrBuf if !r.Quiet { session.Stdout = os.Stdout session.Stderr = os.Stderr - } else { - session.Stderr = &stderrBuf } // Start the command if err := session.Start(command); err != nil { diff --git a/internal/testuser/hash.go b/internal/testuser/hash.go index 1a5cd6a4a..6ec901b18 100644 --- a/internal/testuser/hash.go +++ b/internal/testuser/hash.go @@ -6,27 +6,8 @@ package testuser import ( "crypto/sha256" "encoding/hex" - "fmt" - "os" ) -// HashPassword hashes a password using Codesphere's double-SHA256 scheme with salts. -// The salts are read from environment variables OMS_CS_SALT_1 and OMS_CS_SALT_2. -func HashPassword(password string) (string, error) { - salt1 := os.Getenv("OMS_CS_SALT_1") - if salt1 == "" { - return "", fmt.Errorf("OMS_CS_SALT_1 environment variable is not set") - } - salt2 := os.Getenv("OMS_CS_SALT_2") - if salt2 == "" { - return "", fmt.Errorf("OMS_CS_SALT_2 environment variable is not set") - } - - hashed := hashSecret(password, salt1) - hashed = hashSecret(hashed, salt2) - return hashed, nil -} - // HashAPIToken hashes an API token using a single SHA256 with no additional salt. func HashAPIToken(apiToken string) string { return hashSecret(apiToken, "") diff --git a/internal/testuser/hash_test.go b/internal/testuser/hash_test.go index a1913881b..2c972a383 100644 --- a/internal/testuser/hash_test.go +++ b/internal/testuser/hash_test.go @@ -4,63 +4,10 @@ package testuser import ( - "os" - . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("HashPassword", func() { - BeforeEach(func() { - GinkgoHelper() - Expect(os.Setenv("OMS_CS_SALT_1", "testsalt1")).To(Succeed()) - Expect(os.Setenv("OMS_CS_SALT_2", "testsalt2")).To(Succeed()) - }) - - AfterEach(func() { - GinkgoHelper() - Expect(os.Unsetenv("OMS_CS_SALT_1")).To(Succeed()) - Expect(os.Unsetenv("OMS_CS_SALT_2")).To(Succeed()) - }) - - It("produces a deterministic result", func() { - hash1, err := HashPassword("Test1234!") - Expect(err).NotTo(HaveOccurred()) - hash2, err := HashPassword("Test1234!") - Expect(err).NotTo(HaveOccurred()) - Expect(hash1).To(Equal(hash2)) - }) - - It("produces a valid 64-char hex string", func() { - hash, err := HashPassword("Test1234!") - Expect(err).NotTo(HaveOccurred()) - Expect(hash).To(HaveLen(64)) - Expect(hash).To(MatchRegexp("^[0-9a-f]{64}$")) - }) - - It("produces different hashes for different inputs", func() { - hash1, err := HashPassword("password1") - Expect(err).NotTo(HaveOccurred()) - hash2, err := HashPassword("password2") - Expect(err).NotTo(HaveOccurred()) - Expect(hash1).NotTo(Equal(hash2)) - }) - - It("returns an error when SALT_1 is not set", func() { - Expect(os.Unsetenv("OMS_CS_SALT_1")).To(Succeed()) - _, err := HashPassword("Test1234!") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("OMS_CS_SALT_1")) - }) - - It("returns an error when SALT_2 is not set", func() { - Expect(os.Unsetenv("OMS_CS_SALT_2")).To(Succeed()) - _, err := HashPassword("Test1234!") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("OMS_CS_SALT_2")) - }) -}) - var _ = Describe("HashAPIToken", func() { It("produces a deterministic result", func() { hash1 := HashAPIToken("testtoken") @@ -85,16 +32,4 @@ var _ = Describe("HashAPIToken", func() { hash2 := HashAPIToken("token2") Expect(hash1).NotTo(Equal(hash2)) }) - - It("differs from HashPassword for the same input", func() { - Expect(os.Setenv("OMS_CS_SALT_1", "testsalt1")).To(Succeed()) - Expect(os.Setenv("OMS_CS_SALT_2", "testsalt2")).To(Succeed()) - defer func() { Expect(os.Unsetenv("OMS_CS_SALT_1")).To(Succeed()) }() - defer func() { Expect(os.Unsetenv("OMS_CS_SALT_2")).To(Succeed()) }() - - password, err := HashPassword("testtoken") - Expect(err).NotTo(HaveOccurred()) - token := HashAPIToken("testtoken") - Expect(password).NotTo(Equal(token)) - }) }) diff --git a/internal/testuser/testuser.go b/internal/testuser/testuser.go index bb01580ed..4cc70997c 100644 --- a/internal/testuser/testuser.go +++ b/internal/testuser/testuser.go @@ -96,11 +96,6 @@ func New(opts CreateTestUserOpts) (*TestUserCreator, error) { return &TestUserCreator{opts: opts, db: db}, nil } -// newWithDB creates a TestUserCreator with an already-opened DB. -func newWithDB(opts CreateTestUserOpts, db *sql.DB) *TestUserCreator { - return &TestUserCreator{opts: opts, db: db} -} - // close closes the underlying database connection. func (c *TestUserCreator) close() error { if c.db != nil { @@ -111,9 +106,13 @@ func (c *TestUserCreator) close() error { // Create generates credentials and inserts the test user into the database. func (c *TestUserCreator) Create() (*TestUserResult, error) { - plaintextPassword, err := generatePassword() - if err != nil { - return nil, fmt.Errorf("failed to generate password: %w", err) + plaintextPassword := os.Getenv("OMS_CS_TEST_USER_PASSWORD") + if plaintextPassword == "" { + return nil, fmt.Errorf("OMS_CS_TEST_USER_PASSWORD environment variable is not set") + } + hashedPassword := os.Getenv("OMS_CS_TEST_USER_PASSWORD_HASHED") + if hashedPassword == "" { + return nil, fmt.Errorf("OMS_CS_TEST_USER_PASSWORD_HASHED environment variable is not set") } plaintextToken, err := generateAPIToken() @@ -121,10 +120,6 @@ func (c *TestUserCreator) Create() (*TestUserResult, error) { return nil, fmt.Errorf("failed to generate API token: %w", err) } - hashedPassword, err := HashPassword(plaintextPassword) - if err != nil { - return nil, fmt.Errorf("failed to hash password: %w", err) - } hashedToken := HashAPIToken(plaintextToken) result, err := c.createInDB(hashedPassword, hashedToken) @@ -313,11 +308,3 @@ func generateAPIToken() (string, error) { } return tokenPrefix + hex.EncodeToString(b), nil } - -func generatePassword() (string, error) { - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - return "", fmt.Errorf("failed to generate random bytes: %w", err) - } - return hex.EncodeToString(b), nil -} diff --git a/internal/testuser/testuser_test.go b/internal/testuser/testuser_test.go index bfa5c5095..b83e5f542 100644 --- a/internal/testuser/testuser_test.go +++ b/internal/testuser/testuser_test.go @@ -102,33 +102,6 @@ var _ = Describe("WriteResultToFile", func() { }) }) -var _ = Describe("generatePassword", func() { - It("returns no error", func() { - _, err := generatePassword() - Expect(err).NotTo(HaveOccurred()) - }) - - It("has the correct length (32 hex chars from 16 bytes)", func() { - password, err := generatePassword() - Expect(err).NotTo(HaveOccurred()) - Expect(password).To(HaveLen(32)) - }) - - It("produces unique passwords on successive calls", func() { - pw1, err := generatePassword() - Expect(err).NotTo(HaveOccurred()) - pw2, err := generatePassword() - Expect(err).NotTo(HaveOccurred()) - Expect(pw1).NotTo(Equal(pw2)) - }) - - It("contains only hex characters", func() { - password, err := generatePassword() - Expect(err).NotTo(HaveOccurred()) - Expect(password).To(MatchRegexp("^[0-9a-f]{32}$")) - }) -}) - var _ = Describe("createInDB", func() { const ( hashedPassword = "fakehashedpassword" @@ -176,7 +149,7 @@ var _ = Describe("createInDB", func() { // Expect: commit m.ExpectCommit() - result, err := newWithDB(CreateTestUserOpts{Host: "test", Password: "test"}, sqlDB).createInDB(hashedPassword, hashedToken) + result, err := (&TestUserCreator{opts: CreateTestUserOpts{Host: "test", Password: "test"}, db: sqlDB}).createInDB(hashedPassword, hashedToken) Expect(err).NotTo(HaveOccurred()) Expect(result.Email).To(Equal(TestEmail)) Expect(m.ExpectationsWereMet()).NotTo(HaveOccurred()) @@ -191,7 +164,7 @@ var _ = Describe("createInDB", func() { WithArgs(TestEmail). WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) - _, err = newWithDB(CreateTestUserOpts{Host: "test", Password: "test"}, sqlDB).createInDB(hashedPassword, hashedToken) + _, err = (&TestUserCreator{opts: CreateTestUserOpts{Host: "test", Password: "test"}, db: sqlDB}).createInDB(hashedPassword, hashedToken) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("already exists")) Expect(m.ExpectationsWereMet()).NotTo(HaveOccurred()) @@ -212,7 +185,7 @@ var _ = Describe("createInDB", func() { WillReturnError(fmt.Errorf("unique_violation")) m.ExpectRollback() - _, err = newWithDB(CreateTestUserOpts{Host: "test", Password: "test"}, sqlDB).createInDB(hashedPassword, hashedToken) + _, err = (&TestUserCreator{opts: CreateTestUserOpts{Host: "test", Password: "test"}, db: sqlDB}).createInDB(hashedPassword, hashedToken) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to insert credentials")) Expect(m.ExpectationsWereMet()).NotTo(HaveOccurred()) @@ -239,7 +212,7 @@ var _ = Describe("createInDB", func() { WillReturnError(fmt.Errorf("db error")) m.ExpectRollback() - _, err = newWithDB(CreateTestUserOpts{Host: "test", Password: "test"}, sqlDB).createInDB(hashedPassword, hashedToken) + _, err = (&TestUserCreator{opts: CreateTestUserOpts{Host: "test", Password: "test"}, db: sqlDB}).createInDB(hashedPassword, hashedToken) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to insert team")) Expect(m.ExpectationsWereMet()).NotTo(HaveOccurred()) From 4db0b318019546ce2eb184112b6fe545c1eae14e Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:27:28 +0000 Subject: [PATCH 09/12] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- NOTICE | 12 ++++++------ internal/tmpl/NOTICE | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/NOTICE b/NOTICE index e5009b9a6..2b6a4983e 100644 --- a/NOTICE +++ b/NOTICE @@ -653,9 +653,9 @@ License URL: https://github.com/ianlancetaylor/demangle/blob/96ee0021ea0f/LICENS ---------- Module: github.com/jedib0t/go-pretty/v6 -Version: v6.7.9 +Version: v6.7.10 License: MIT -License URL: https://github.com/jedib0t/go-pretty/blob/v6.7.9/LICENSE +License URL: https://github.com/jedib0t/go-pretty/blob/v6.7.10/LICENSE ---------- Module: github.com/jmoiron/sqlx @@ -905,9 +905,9 @@ License URL: https://github.com/prometheus/procfs/blob/v0.20.1/LICENSE ---------- Module: github.com/rook/rook/pkg/apis/ceph.rook.io -Version: v0.0.0-20260421122039-59ce48ae88e5 +Version: v0.0.0-20260424083917-b87c434ff9a9 License: Apache-2.0 -License URL: https://github.com/rook/rook/blob/59ce48ae88e5/pkg/apis/LICENSE +License URL: https://github.com/rook/rook/blob/b87c434ff9a9/pkg/apis/LICENSE ---------- Module: github.com/rubenv/sql-migrate @@ -1211,9 +1211,9 @@ License URL: https://github.com/grpc/grpc-go/blob/v1.80.0/LICENSE ---------- Module: google.golang.org/protobuf -Version: v1.36.11 +Version: v1.36.12-0.20260120151049-f2248ac996af License: BSD-3-Clause -License URL: https://github.com/protocolbuffers/protobuf-go/blob/v1.36.11/LICENSE +License URL: https://github.com/protocolbuffers/protobuf-go/blob/f2248ac996af/LICENSE ---------- Module: gopkg.in/evanphx/json-patch.v4 diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index e5009b9a6..2b6a4983e 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -653,9 +653,9 @@ License URL: https://github.com/ianlancetaylor/demangle/blob/96ee0021ea0f/LICENS ---------- Module: github.com/jedib0t/go-pretty/v6 -Version: v6.7.9 +Version: v6.7.10 License: MIT -License URL: https://github.com/jedib0t/go-pretty/blob/v6.7.9/LICENSE +License URL: https://github.com/jedib0t/go-pretty/blob/v6.7.10/LICENSE ---------- Module: github.com/jmoiron/sqlx @@ -905,9 +905,9 @@ License URL: https://github.com/prometheus/procfs/blob/v0.20.1/LICENSE ---------- Module: github.com/rook/rook/pkg/apis/ceph.rook.io -Version: v0.0.0-20260421122039-59ce48ae88e5 +Version: v0.0.0-20260424083917-b87c434ff9a9 License: Apache-2.0 -License URL: https://github.com/rook/rook/blob/59ce48ae88e5/pkg/apis/LICENSE +License URL: https://github.com/rook/rook/blob/b87c434ff9a9/pkg/apis/LICENSE ---------- Module: github.com/rubenv/sql-migrate @@ -1211,9 +1211,9 @@ License URL: https://github.com/grpc/grpc-go/blob/v1.80.0/LICENSE ---------- Module: google.golang.org/protobuf -Version: v1.36.11 +Version: v1.36.12-0.20260120151049-f2248ac996af License: BSD-3-Clause -License URL: https://github.com/protocolbuffers/protobuf-go/blob/v1.36.11/LICENSE +License URL: https://github.com/protocolbuffers/protobuf-go/blob/f2248ac996af/LICENSE ---------- Module: gopkg.in/evanphx/json-patch.v4 From f3932a39f7265d8cde288171c2bb2cd55ea44fb5 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:03:41 +0200 Subject: [PATCH 10/12] fix: update default value for registry-type flag in bootstrap GCP command --- cli/cmd/bootstrap_gcp.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/cmd/bootstrap_gcp.go b/cli/cmd/bootstrap_gcp.go index 61bd1e6af..665979f82 100644 --- a/cli/cmd/bootstrap_gcp.go +++ b/cli/cmd/bootstrap_gcp.go @@ -85,7 +85,7 @@ func AddBootstrapGcpCmd(parent *cobra.Command, opts *GlobalOptions) { flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.InstallHash, "install-hash", "", "Codesphere package hash to install (default: none)") flags.StringArrayVarP(&bootstrapGcpCmd.CodesphereEnv.InstallSkipSteps, "install-skip-steps", "s", []string{}, "Installation steps to skip during Codesphere installation (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.RegistryUser, "registry-user", "", "Custom Registry username (only for GitHub registry type) (optional)") - flags.StringVar(&bootstrapGcpCmd.InputRegistryType, "registry-type", "local-container", "Container registry type to use (options: local-container, artifact-registry) (default: artifact-registry)") + flags.StringVar(&bootstrapGcpCmd.InputRegistryType, "registry-type", "local-container", "Container registry type to use (options: local-container, artifact-registry) (default: local-container)") flags.StringArrayVar(&bootstrapGcpCmd.CodesphereEnv.Experiments, "experiments", gcp.DefaultExperiments, "Experiments to enable in Codesphere installation (optional)") flags.StringArrayVar(&bootstrapGcpCmd.FeatureFlagList, "feature-flags", []string{}, "Feature flags to enable in Codesphere installation (optional)") From a2cc75721197cb2c5e620fdd98987b7b7775475d Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:04:48 +0000 Subject: [PATCH 11/12] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- docs/oms_beta_bootstrap-gcp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/oms_beta_bootstrap-gcp.md b/docs/oms_beta_bootstrap-gcp.md index 3ed8abb8a..93fca5d9d 100644 --- a/docs/oms_beta_bootstrap-gcp.md +++ b/docs/oms_beta_bootstrap-gcp.md @@ -48,7 +48,7 @@ oms beta bootstrap-gcp [flags] --project-ttl string Time to live for the GCP project. Cleanup workflows will remove it afterwards. (default: 2 hours) (default "2h") --recover-config Recover previously generated install config from the jumpbox. This will overwrite the local config! (default: false) --region string GCP Region (default: europe-west4) (default "europe-west4") - --registry-type string Container registry type to use (options: local-container, artifact-registry) (default: artifact-registry) (default "local-container") + --registry-type string Container registry type to use (options: local-container, artifact-registry) (default: local-container) (default "local-container") --registry-user string Custom Registry username (only for GitHub registry type) (optional) --secrets-dir string Directory for secrets (default: /etc/codesphere/secrets) (default "/etc/codesphere/secrets") --secrets-file string Path to secrets files (optional) (default "prod.vault.yaml") From 0fe35cedcb06556b6cd5c667c1e2949fd832f9c8 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:12:04 +0200 Subject: [PATCH 12/12] fix: remove unused salt parameter from hashSecret function --- internal/testuser/hash.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/testuser/hash.go b/internal/testuser/hash.go index 6ec901b18..4be3b1fa6 100644 --- a/internal/testuser/hash.go +++ b/internal/testuser/hash.go @@ -10,11 +10,11 @@ import ( // HashAPIToken hashes an API token using a single SHA256 with no additional salt. func HashAPIToken(apiToken string) string { - return hashSecret(apiToken, "") + return hashSecret(apiToken) } -func hashSecret(secret, salt string) string { +func hashSecret(secret string) string { hasher := sha256.New() - _, _ = hasher.Write([]byte(secret + salt)) + _, _ = hasher.Write([]byte(secret)) return hex.EncodeToString(hasher.Sum(nil)) }