diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index a60ea64d..47ec1f23 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -240,7 +240,7 @@ func downloadURL(url string) ([]byte, error) { func verifyChecksum(data []byte, expected string) error { h := sha256.Sum256(data) got := hex.EncodeToString(h[:]) - if got != strings.ToLower(expected) { + if !strings.EqualFold(got, expected) { return fmt.Errorf("checksum mismatch: got %s, want %s", got, expected) } return nil diff --git a/example/go.mod b/example/go.mod index 75fcc97a..f715e401 100644 --- a/example/go.mod +++ b/example/go.mod @@ -21,7 +21,7 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/CrisisTextLine/modular/modules/auth v0.4.0 // indirect - github.com/CrisisTextLine/modular/modules/eventbus v1.6.0 // indirect + github.com/CrisisTextLine/modular/modules/eventbus/v2 v2.0.0 // indirect github.com/DataDog/datadog-go/v5 v5.4.0 // indirect github.com/GoCodeAlone/yaegi v0.17.1 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect @@ -105,7 +105,7 @@ require ( github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.1 // indirect + github.com/klauspost/compress v1.18.3 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect @@ -113,8 +113,8 @@ require ( github.com/moby/sys/sequential v0.6.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/nats-io/nats.go v1.46.1 // indirect - github.com/nats-io/nkeys v0.4.11 // indirect + github.com/nats-io/nats.go v1.48.0 // indirect + github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect diff --git a/example/go.sum b/example/go.sum index 5ca773de..82d86e36 100644 --- a/example/go.sum +++ b/example/go.sum @@ -30,8 +30,10 @@ github.com/CrisisTextLine/modular/modules/auth v0.4.0 h1:sP7CYgdJPz88M1PrfOz2knw github.com/CrisisTextLine/modular/modules/auth v0.4.0/go.mod h1:0DnUawpxdFCka4BjMhmXubQIjkF4VRwRdN6c64Jbvvo= github.com/CrisisTextLine/modular/modules/cache v0.4.0 h1:vlPXAsucSM1M0RsPly9cWyODouMLQMUwhW/wltQZHZk= github.com/CrisisTextLine/modular/modules/cache v0.4.0/go.mod h1:4irZOGXxUlgJqAnWlpMyPC3C1tM/f5145/wMThYnAsY= -github.com/CrisisTextLine/modular/modules/eventbus v1.6.0 h1:40H5/mrhPw3Jzi9wntg2//7YVAC4gIgCZJX7sBhXDmU= -github.com/CrisisTextLine/modular/modules/eventbus v1.6.0/go.mod h1:I1tGf3DmadwyMP2NE2m6XHYl9ebXB9wBc/KZLywTR4c= +github.com/CrisisTextLine/modular/modules/eventbus v1.7.0 h1:SSeu7rjuECDgFa+iNyndn94YPQxffHxJgfR7U4psz6E= +github.com/CrisisTextLine/modular/modules/eventbus v1.7.0/go.mod h1:I1tGf3DmadwyMP2NE2m6XHYl9ebXB9wBc/KZLywTR4c= +github.com/CrisisTextLine/modular/modules/eventbus/v2 v2.0.0 h1:bDNWBparvVzXnhLxjFPJ9MDg7N4NUnNOjfn56G/CwGU= +github.com/CrisisTextLine/modular/modules/eventbus/v2 v2.0.0/go.mod h1:5DmacIYrhhiN18i2OHyAVBiNKbN2jHuEv2UJoRToMg0= github.com/CrisisTextLine/modular/modules/reverseproxy/v2 v2.2.0 h1:SUJEPA61IbjdUwKdSembQTbX9rKz5v4vmyr/cbvb4tY= github.com/CrisisTextLine/modular/modules/reverseproxy/v2 v2.2.0/go.mod h1:/jVQz+0c/OSm0KcLElNAQueI5BoLd48l1KHV4Np+RO8= github.com/CrisisTextLine/modular/modules/scheduler v0.4.0 h1:PDYAD+hL7E6mM7YJey9ag1dnTTcJwsepoylxfZY8trw= @@ -53,6 +55,8 @@ github.com/IBM/sarama v1.46.3/go.mod h1:GTUYiF9DMOZVe3FwyGT+dtSPceGFIgA+sPc5u6CB github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op h1:Ucf+QxEKMbPogRO5guBNe5cgd9uZgfoJLOYs8WWhtjM= +github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= @@ -184,6 +188,8 @@ github.com/golobby/cast v1.3.3 h1:s2Lawb9RMz7YyYf8IrfMQY4IFmA1R/lgfmj97Vc6fig= github.com/golobby/cast v1.3.3/go.mod h1:0oDO5IT84HTXcbLDf1YXuk0xtg/cRDrxhbpWKxwtJCY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= @@ -261,8 +267,8 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= -github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -276,6 +282,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -295,10 +303,14 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= -github.com/nats-io/nats.go v1.46.1 h1:bqQ2ZcxVd2lpYI97xYASeRTY3I5boe/IVmuUDPitHfo= -github.com/nats-io/nats.go v1.46.1/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= -github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= -github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= +github.com/nats-io/jwt/v2 v2.8.0 h1:K7uzyz50+yGZDO5o772eRE7atlcSEENpL7P+b74JV1g= +github.com/nats-io/jwt/v2 v2.8.0/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA= +github.com/nats-io/nats-server/v2 v2.12.4 h1:ZnT10v2LU2Xcoiy8ek9X6Se4YG8EuMfIfvAEuFVx1Ts= +github.com/nats-io/nats-server/v2 v2.12.4/go.mod h1:5MCp/pqm5SEfsvVZ31ll1088ZTwEUdvRX1Hmh/mTTDg= +github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= +github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= +github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= diff --git a/module/http_router.go b/module/http_router.go index 4f7890ea..88d0ef61 100644 --- a/module/http_router.go +++ b/module/http_router.go @@ -11,6 +11,13 @@ import ( "github.com/CrisisTextLine/modular" ) +// contextKey is a private type for context keys defined in this package, +// preventing collisions with keys defined in other packages. +type contextKey string + +// paramsKey is the context key used to store extracted path parameters. +const paramsKey contextKey = "params" + // pathParamRe matches {name} or {name...} segments in a URL path template. var pathParamRe = regexp.MustCompile(`\{(\w+)(?:\.\.\.)?}`) @@ -170,9 +177,9 @@ func (r *StandardHTTPRouter) rebuildMuxLocked() { mux := http.NewServeMux() for _, route := range r.routes { mux.HandleFunc(fmt.Sprintf("%s %s", route.Method, route.Path), func(w http.ResponseWriter, r *http.Request) { - // Inject path params into context so triggers can read them via r.Context().Value("params"). + // Inject path params into context so triggers can read them via r.Context().Value(paramsKey). if params := extractRouteParams(route.Path, r); len(params) > 0 { - r = r.WithContext(context.WithValue(r.Context(), "params", params)) + r = r.WithContext(context.WithValue(r.Context(), paramsKey, params)) } // Create handler chain with middleware diff --git a/module/http_trigger.go b/module/http_trigger.go index 17d77f16..26648b63 100644 --- a/module/http_trigger.go +++ b/module/http_trigger.go @@ -247,7 +247,7 @@ func (t *HTTPTrigger) createHandler(route HTTPTriggerRoute) HTTPHandler { handlerFn := func(w http.ResponseWriter, r *http.Request) { // Extract path parameters from the context (would have been set by the router) params := make(map[string]string) - if routeParams, ok := r.Context().Value("params").(map[string]string); ok { + if routeParams, ok := r.Context().Value(paramsKey).(map[string]string); ok { params = routeParams } diff --git a/ui/src/components/auth/LoginPage.tsx b/ui/src/components/auth/LoginPage.tsx index 8b8cc166..dacf195e 100644 --- a/ui/src/components/auth/LoginPage.tsx +++ b/ui/src/components/auth/LoginPage.tsx @@ -169,6 +169,7 @@ export default function LoginPage() { onChange={(e) => setPassword(e.target.value)} placeholder="Enter password" required + autoComplete={mode === 'signin' ? 'current-password' : 'new-password'} style={inputStyle} /> @@ -184,6 +185,7 @@ export default function LoginPage() { onChange={(e) => setConfirmPassword(e.target.value)} placeholder="Confirm password" required + autoComplete="new-password" style={inputStyle} /> diff --git a/ui/src/components/auth/SetupWizard.tsx b/ui/src/components/auth/SetupWizard.tsx index 9068beb5..2889eba4 100644 --- a/ui/src/components/auth/SetupWizard.tsx +++ b/ui/src/components/auth/SetupWizard.tsx @@ -116,6 +116,7 @@ export default function SetupWizard() { onChange={(e) => setPassword(e.target.value)} placeholder="Min 6 characters" required + autoComplete="new-password" style={inputStyle} /> @@ -130,6 +131,7 @@ export default function SetupWizard() { onChange={(e) => setConfirmPassword(e.target.value)} placeholder="Confirm password" required + autoComplete="new-password" style={inputStyle} /> diff --git a/ui/src/store/authStore.test.ts b/ui/src/store/authStore.test.ts index 224f3e5a..71387b3a 100644 --- a/ui/src/store/authStore.test.ts +++ b/ui/src/store/authStore.test.ts @@ -46,7 +46,8 @@ function mockFetchFailure(message: string, status = 401) { ok: false, status, statusText: 'Unauthorized', - text: () => Promise.resolve(message), + json: () => Promise.resolve({ error: message }), + text: () => Promise.resolve(JSON.stringify({ error: message })), }); } @@ -122,7 +123,7 @@ describe('authStore', () => { expect(state.user?.email).toBe('test@example.com'); }); - it('sets error on failure', async () => { + it('maps "Invalid credentials" to friendly error message', async () => { globalThis.fetch = mockFetchFailure('Invalid credentials'); await act(async () => { @@ -131,7 +132,7 @@ describe('authStore', () => { const state = useAuthStore.getState(); expect(state.isAuthenticated).toBe(false); - expect(state.error).toBe('Invalid credentials'); + expect(state.error).toBe('Invalid email or password'); expect(state.isLoading).toBe(false); }); @@ -200,18 +201,102 @@ describe('authStore', () => { expect(state.user?.display_name).toBe('New User'); }); - it('sets error on registration failure', async () => { + it('maps "Email already exists" to friendly error message', async () => { globalThis.fetch = mockFetchFailure('Email already exists', 409); await act(async () => { await useAuthStore.getState().register('dup@example.com', 'pw', 'Dup'); }); - expect(useAuthStore.getState().error).toBe('Email already exists'); + expect(useAuthStore.getState().error).toBe('An account with this email already exists'); expect(useAuthStore.getState().isAuthenticated).toBe(false); }); }); + describe('login error transformation', () => { + function setupLoginFailure(errorMessage: string) { + globalThis.fetch = mockFetchFailure(errorMessage); + } + + it.each([ + 'invalid credentials', + 'Invalid credentials', + 'invalid password', + 'invalid user', + 'unauthorized', + 'Unauthorized', + 'HTTP 401', + ])('maps "%s" to "Invalid email or password"', async (msg) => { + setupLoginFailure(msg); + await act(async () => { + await useAuthStore.getState().login('a@b.com', 'pw'); + }); + expect(useAuthStore.getState().error).toBe('Invalid email or password'); + }); + + it.each([ + 'Internal server error', + 'request timeout', + 'Error 4010', + 'Code: 14015', + 'something went wrong', + ])('passes through "%s" unchanged', async (msg) => { + setupLoginFailure(msg); + await act(async () => { + await useAuthStore.getState().login('a@b.com', 'pw'); + }); + expect(useAuthStore.getState().error).toBe(msg); + }); + + it('does not match partial status codes like "4010"', async () => { + setupLoginFailure('Error 4010'); + await act(async () => { + await useAuthStore.getState().login('a@b.com', 'pw'); + }); + expect(useAuthStore.getState().error).toBe('Error 4010'); + }); + + it('does not match partial status codes like "14015"', async () => { + setupLoginFailure('Code: 14015'); + await act(async () => { + await useAuthStore.getState().login('a@b.com', 'pw'); + }); + expect(useAuthStore.getState().error).toBe('Code: 14015'); + }); + }); + + describe('register error transformation', () => { + function setupRegisterFailure(errorMessage: string) { + globalThis.fetch = mockFetchFailure(errorMessage, 409); + } + + it.each([ + 'email already exists', + 'Email already exists', + 'already registered', + 'Already registered', + 'duplicate email address', + ])('maps "%s" to friendly message', async (msg) => { + setupRegisterFailure(msg); + await act(async () => { + await useAuthStore.getState().register('a@b.com', 'pw', 'Test'); + }); + expect(useAuthStore.getState().error).toBe('An account with this email already exists'); + }); + + it.each([ + 'password too short', + 'Internal server error', + 'invalid email format', + ])('passes through "%s" unchanged', async (msg) => { + setupRegisterFailure(msg); + await act(async () => { + await useAuthStore.getState().register('a@b.com', 'pw', 'Test'); + }); + expect(useAuthStore.getState().error).toBe(msg); + }); + }); + describe('logout', () => { it('clears token, user, and sets isAuthenticated false', () => { useAuthStore.setState({ diff --git a/ui/src/store/authStore.ts b/ui/src/store/authStore.ts index e2861914..54134089 100644 --- a/ui/src/store/authStore.ts +++ b/ui/src/store/authStore.ts @@ -99,10 +99,11 @@ const useAuthStore = create((set, get) => ({ scheduleRefresh(data.expires_in, get().refreshAuth); await get().loadUser(); } catch (err) { - set({ - isLoading: false, - error: err instanceof Error ? err.message : 'Login failed', - }); + const raw = err instanceof Error ? err.message : 'Login failed'; + const friendly = /(invalid.*(cred|password|user)|unauthorized|\b401\b)/i.test(raw) + ? 'Invalid email or password' + : raw; + set({ isLoading: false, error: friendly }); } }, @@ -128,10 +129,11 @@ const useAuthStore = create((set, get) => ({ scheduleRefresh(data.expires_in, get().refreshAuth); await get().loadUser(); } catch (err) { - set({ - isLoading: false, - error: err instanceof Error ? err.message : 'Registration failed', - }); + const raw = err instanceof Error ? err.message : 'Registration failed'; + const friendly = /(already\s*(exists|registered)|duplicate.*email)/i.test(raw) + ? 'An account with this email already exists' + : raw; + set({ isLoading: false, error: friendly }); } },