diff --git a/Makefile b/Makefile index 17378aa4..5c323475 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ APP_BUILD_NUMBER := $(if $(APP_BUILD_NUMBER_RAW),$(APP_BUILD_NUMBER_RAW),1) APP_DART_DEFINE_VERSION ?= --dart-define=XWORKMATE_DISPLAY_VERSION=$(APP_VERSION) APP_DART_DEFINE_BUILD ?= --dart-define=XWORKMATE_BUILD_NUMBER=$(APP_BUILD_NUMBER) -.PHONY: help deps analyze test check format run open-macos-xcode sync-version build-linux build-macos build-ios-sim package-deb package-rpm package-linux package-mac install-mac clean build-go-core render-release-docs check-export-compliance +.PHONY: help deps analyze test test-all test-flutter test-golden test-integration test-integration-macos test-patrol test-go test-ci check format run open-macos-xcode sync-version build-linux build-macos build-ios-sim package-deb package-rpm package-linux package-mac install-mac clean build-go-core render-release-docs check-export-compliance help: ## Show available targets @grep -E '^[a-zA-Z0-9_.-]+:.*?## ' Makefile | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "%-18s %s\n", $$1, $$2}' @@ -30,6 +30,30 @@ analyze: ## Run static analysis test: ## Run Flutter tests $(FLUTTER) test +test-flutter: ## Run the full Flutter unit/widget test suite + $(FLUTTER) test + +test-golden: ## Run Flutter Golden tests + $(FLUTTER) test test/golden + +test-integration: ## Run Flutter integration tests + $(FLUTTER) test integration_test + +test-integration-macos: ## Run macOS integration tests serially for the desktop app + $(FLUTTER) test integration_test/desktop_navigation_flow_test.dart -d macos + $(FLUTTER) test integration_test/desktop_settings_flow_test.dart -d macos + +test-patrol: ## Run Patrol end-to-end tests + dart pub global activate patrol_cli + patrol test + +test-go: ## Run Go API unit tests + cd go_service && go test ./... + +test-ci: test-flutter test-golden test-integration test-go ## Run the PR validation chain + +test-all: test-ci test-patrol ## Run the full local validation chain + check: analyze test ## Run the standard validation suite format: ## Format Dart sources diff --git a/go/go_core/internal/handler/auth_handler.go b/go/go_core/internal/handler/auth_handler.go new file mode 100644 index 00000000..16f5148e --- /dev/null +++ b/go/go_core/internal/handler/auth_handler.go @@ -0,0 +1,49 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "xworkmate/go_core/internal/service" +) + +type Authenticator interface { + Authenticate(username, password string) error +} + +type AuthHandler struct { + service Authenticator +} + +func NewAuthHandler(svc Authenticator) *AuthHandler { + return &AuthHandler{service: svc} +} + +func NewServiceAdapter(svc *service.AuthService) Authenticator { + return authServiceAdapter{service: svc} +} + +func (h *AuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + var payload struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if err := h.service.Authenticate(payload.Username, payload.Password); err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +type authServiceAdapter struct { + service *service.AuthService +} + +func (a authServiceAdapter) Authenticate(username, password string) error { + return a.service.Authenticate(nil, username, password) +} diff --git a/go/go_core/internal/handler/auth_handler_test.go b/go/go_core/internal/handler/auth_handler_test.go new file mode 100644 index 00000000..a900a293 --- /dev/null +++ b/go/go_core/internal/handler/auth_handler_test.go @@ -0,0 +1,53 @@ +package handler + +import ( + "bytes" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +type fakeAuthenticator struct { + err error +} + +func (f fakeAuthenticator) Authenticate(username, password string) error { + return f.err +} + +func TestAuthHandlerRejectsInvalidJSON(t *testing.T) { + handler := NewAuthHandler(fakeAuthenticator{}) + req := httptest.NewRequest(http.MethodPost, "/auth", bytes.NewBufferString("{")) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestAuthHandlerReturnsUnauthorizedOnServiceFailure(t *testing.T) { + handler := NewAuthHandler(fakeAuthenticator{err: errors.New("invalid credentials")}) + req := httptest.NewRequest(http.MethodPost, "/auth", bytes.NewBufferString(`{"username":"alice","password":"secret"}`)) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } +} + +func TestAuthHandlerReturnsOKOnSuccess(t *testing.T) { + handler := NewAuthHandler(fakeAuthenticator{}) + req := httptest.NewRequest(http.MethodPost, "/auth", bytes.NewBufferString(`{"username":"alice","password":"secret"}`)) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } +} diff --git a/go/go_core/internal/service/auth_service.go b/go/go_core/internal/service/auth_service.go new file mode 100644 index 00000000..69751f83 --- /dev/null +++ b/go/go_core/internal/service/auth_service.go @@ -0,0 +1,37 @@ +package service + +import ( + "context" + "errors" + "strings" +) + +var ErrInvalidCredentials = errors.New("invalid credentials") + +type AuthRepository interface { + Verify(ctx context.Context, username, password string) (bool, error) +} + +type AuthService struct { + repo AuthRepository +} + +func NewAuthService(repo AuthRepository) *AuthService { + return &AuthService{repo: repo} +} + +func (s *AuthService) Authenticate(ctx context.Context, username, password string) error { + username = strings.TrimSpace(username) + password = strings.TrimSpace(password) + if username == "" || password == "" { + return ErrInvalidCredentials + } + ok, err := s.repo.Verify(ctx, username, password) + if err != nil { + return err + } + if !ok { + return ErrInvalidCredentials + } + return nil +} diff --git a/go/go_core/internal/service/auth_service_test.go b/go/go_core/internal/service/auth_service_test.go new file mode 100644 index 00000000..26c56ba7 --- /dev/null +++ b/go/go_core/internal/service/auth_service_test.go @@ -0,0 +1,55 @@ +package service + +import ( + "context" + "errors" + "testing" +) + +type fakeAuthRepo struct { + verify func(ctx context.Context, username, password string) (bool, error) +} + +func (f fakeAuthRepo) Verify(ctx context.Context, username, password string) (bool, error) { + return f.verify(ctx, username, password) +} + +func TestAuthenticateRejectsBlankValues(t *testing.T) { + svc := NewAuthService(fakeAuthRepo{ + verify: func(ctx context.Context, username, password string) (bool, error) { + return true, nil + }, + }) + + if err := svc.Authenticate(context.Background(), " ", "secret"); !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("expected invalid credentials, got %v", err) + } +} + +func TestAuthenticateRejectsFailedVerification(t *testing.T) { + svc := NewAuthService(fakeAuthRepo{ + verify: func(ctx context.Context, username, password string) (bool, error) { + if username != "alice" || password != "secret" { + t.Fatalf("unexpected credentials: %q %q", username, password) + } + return false, nil + }, + }) + + if err := svc.Authenticate(context.Background(), "alice", "secret"); !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("expected invalid credentials, got %v", err) + } +} + +func TestAuthenticateReturnsRepoError(t *testing.T) { + wanted := errors.New("boom") + svc := NewAuthService(fakeAuthRepo{ + verify: func(ctx context.Context, username, password string) (bool, error) { + return false, wanted + }, + }) + + if err := svc.Authenticate(context.Background(), "alice", "secret"); !errors.Is(err, wanted) { + t.Fatalf("expected repo error, got %v", err) + } +} diff --git a/lib/app/app.dart b/lib/app/app.dart index 04e14f58..169be312 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -84,6 +84,7 @@ class _XWorkmateAppState extends State { animation: _controller, builder: (context, _) { return MaterialApp( + key: const Key('xworkmate-app-shell'), title: kSystemAppName, debugShowCheckedModeBanner: false, locale: Locale(_controller.appLanguage.code), diff --git a/lib/features/assistant/assistant_page_composer_bar.dart b/lib/features/assistant/assistant_page_composer_bar.dart index 7bf43e7c..aa97bf92 100644 --- a/lib/features/assistant/assistant_page_composer_bar.dart +++ b/lib/features/assistant/assistant_page_composer_bar.dart @@ -628,6 +628,7 @@ class ComposerBarStateInternal extends State { ), }, child: TextField( + key: const Key('assistant-input-field'), controller: widget.inputController, focusNode: widget.focusNode, autofocus: true, @@ -789,8 +790,8 @@ class ComposerBarStateInternal extends State { const SizedBox(width: 8), Tooltip( message: submitLabel, - child: FilledButton( - key: const Key('assistant-submit-button'), + child: FilledButton( + key: const Key('assistant-send-button'), onPressed: connecting ? null : connected diff --git a/lib/features/settings/settings_page_gateway_acp.dart b/lib/features/settings/settings_page_gateway_acp.dart index 291c635f..86614f8a 100644 --- a/lib/features/settings/settings_page_gateway_acp.dart +++ b/lib/features/settings/settings_page_gateway_acp.dart @@ -227,7 +227,7 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal { ), ), FilledButton( - key: ValueKey('external-acp-apply-${profile.providerKey}'), + key: ValueKey('external-acp-save-${profile.providerKey}'), onPressed: () => saveExternalAcpEndpointInternal( controller, settings, diff --git a/macos/Podfile.lock b/macos/Podfile.lock index d483bc74..8a80876b 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -64,7 +64,7 @@ SPEC CHECKSUMS: irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba mobile_scanner: 0e365ed56cad24f28c0fd858ca04edefb40dfac3 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 - patrol: 5df5d241d7f95f0df12a6906bbf45acb43a1e537 + patrol: cea8074f183a2a4232d0ebd10569ae05149ada42 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 diff --git a/pubspec.lock b/pubspec.lock index bfddbed2..d65384b9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -543,26 +543,26 @@ packages: dependency: "direct dev" description: name: patrol - sha256: "32fd0709f3871fa56eb9cd88410e3ca816bfa757122bae806a0f842188acb820" + sha256: "7825a6e96a8f0755f68eec600a91a08b19bd0975488a70885b3696f6b65ffc0f" url: "https://pub.dev" source: hosted - version: "3.20.0" + version: "4.5.0" patrol_finders: dependency: transitive description: name: patrol_finders - sha256: "4a658d7d560de523f92deb3fa3326c78747ca0bf7e7f4b8788c012463138b628" + sha256: "9970eac0669a90b20ec7e1bcaabd0475655655998068ca656f4df9f6ec84f336" url: "https://pub.dev" source: hosted - version: "2.9.0" + version: "3.2.0" patrol_log: dependency: transitive description: name: patrol_log - sha256: "9fed4143980df1e3bbcfa00d0b443c7d68f04f9132317b7698bbc37f8a5a58c5" + sha256: a2360db165c34692665c0de146e5157887d6b584fdccca8f141f947a5acf1b2e url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.8.0" pixel_snap: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 44d72978..370a6ef2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,7 +38,7 @@ dev_dependencies: integration_test: sdk: flutter golden_toolkit: ^0.15.0 - patrol: ^3.13.0 + patrol: ^4.3.0 flutter_lints: ^6.0.0 dependency_overrides: diff --git a/test/golden/goldens/home_golden.png b/test/golden/goldens/home_golden.png new file mode 100644 index 00000000..02e861c9 Binary files /dev/null and b/test/golden/goldens/home_golden.png differ diff --git a/test/golden/goldens/login_golden.png b/test/golden/goldens/login_golden.png new file mode 100644 index 00000000..2da14130 Binary files /dev/null and b/test/golden/goldens/login_golden.png differ