diff --git a/auth/provider_common.go b/auth/provider_common.go
index 8aef896b..b989680f 100644
--- a/auth/provider_common.go
+++ b/auth/provider_common.go
@@ -86,6 +86,11 @@ func (p *commonProvider) GetUser(c echo.Context) (*users.User, error) {
func (p *commonProvider) GetSession(c echo.Context) (*Session, error) {
cookie, err := c.Cookie(AuthCookieName)
if err != nil {
+ if err == http.ErrNoCookie {
+ // No session cookie at all is the normal, unauthenticated case
+ // (e.g. a fresh visit) — not a login failure worth reporting.
+ return nil, p.renderer.renderLoginPage(c, "")
+ }
return nil, p.renderer.renderLoginPage(c, err.Error())
} else if len(cookie.Value) == 0 {
return nil, p.renderer.renderLoginPage(c, "")
diff --git a/auth/provider_oauthbase.go b/auth/provider_oauthbase.go
index ea710a13..e309f218 100644
--- a/auth/provider_oauthbase.go
+++ b/auth/provider_oauthbase.go
@@ -83,12 +83,16 @@ func (p oauth2BaseProvider) renderLoginPage(c echo.Context, reason string) error
pagectx.Base
LoginTip string
Name string
- Reason string
+ // ProviderKey selects which provider's icon/copy oauth2-login.html
+ // renders; it is not used for any auth decision.
+ ProviderKey string
+ Reason string
}{
- Base: p.pageCtx.Base(c, "Login", ""),
- LoginTip: p.loginTip,
- Name: p.displayName,
- Reason: reason,
+ Base: p.pageCtx.Base(c, "Login", ""),
+ LoginTip: p.loginTip,
+ Name: p.displayName,
+ ProviderKey: p.name,
+ Reason: reason,
}
return templates.Templates.ExecuteTemplate(c.Response(), "oauth2-login.html", context)
}
diff --git a/contrib/e2e/Makefile b/contrib/e2e/Makefile
index f1ae7828..49e4848a 100644
--- a/contrib/e2e/Makefile
+++ b/contrib/e2e/Makefile
@@ -39,6 +39,7 @@ run: build
$(CACHE)/venv/bin/pytest -s -v test_remote_actions.py
$(CACHE)/venv/bin/pytest -s -v test_updates.py
$(CACHE)/venv/bin/pytest -s -v test_webui.py
+ $(CACHE)/venv/bin/pytest -s -v test_login.py
$(CACHE)/venv/bin/pytest -s -v test_webui_settings.py
$(CACHE)/venv/bin/pytest -s -v test_e2e_update_flow.py
diff --git a/contrib/e2e/README.md b/contrib/e2e/README.md
index de755515..e923f770 100644
--- a/contrib/e2e/README.md
+++ b/contrib/e2e/README.md
@@ -76,6 +76,7 @@ make clean
| `test_remote_actions.py` | `fioup run-and-report`; verifying the result via CLI and the web UI, plus artifact download. |
| `test_updates.py` | Uploading an OTA update artifact and finding it in `updates list`. |
| `test_webui.py` | Web UI smoke tests and device-table rendering. |
+| `test_login.py` | Local username/password login: page render, invalid-credential error, and successful-login redirect/cookie. |
| `test_webui_settings.py` | Creating an API token via the settings dialog and checking the audit log. |
| `test_e2e_update_flow.py` | Full flow: upload an update, create a rollout, install on the device, and verify events plus the running container. |
@@ -87,6 +88,9 @@ and device are set up once per run:
- `update_server` — generates PKI (`add_device.sh` signs a device cert against
the generated device CA), initializes TUF and test-mode auth, and starts
`fioserver serve`, yielding its data directory.
+- `update_server_local_auth` — a separate `fioserver` instance (own PKI/TUF,
+ no device registration) initialized with local username/password auth and
+ a seeded `admin`/`admin` user, for `test_login.py`'s login-flow coverage.
- `fioup_device` / `docker` — launch the `fioup` container and wait for its
inner `dockerd` to be ready.
- `registered_device` — copies the generated device credentials plus a
diff --git a/contrib/e2e/conftest.py b/contrib/e2e/conftest.py
index 52fd14d1..a18fbc60 100644
--- a/contrib/e2e/conftest.py
+++ b/contrib/e2e/conftest.py
@@ -320,6 +320,77 @@ def update_server(request, fioserver_bin):
shutil.rmtree(datadir, ignore_errors=True)
+@pytest.fixture(scope="session")
+def update_server_local_auth(request, fioserver_bin):
+ """Generate PKI, start update-server with local username/password auth.
+
+ Kept separate from `update_server` (rather than parameterizing it) so
+ switching auth modes here doesn't change behavior for the many other
+ tests that already depend on `update_server`'s noauth/test config.
+ """
+ datadir = Path(tempfile.mkdtemp(prefix="fioserver-local-auth-"))
+
+ print("[setup] Initialising auth (local username/password mode) ...", flush=True)
+ subprocess.run(
+ [str(fioserver_bin), "--datadir", str(datadir), "auth-init", "--local"],
+ check=True,
+ capture_output=True,
+ )
+ subprocess.run(
+ [
+ str(fioserver_bin), "--datadir", str(datadir),
+ "user-add", "--username", "admin", "--password", "admin",
+ ],
+ check=True,
+ capture_output=True,
+ )
+
+ print("\n[setup] Generating PKI ...", flush=True)
+ subprocess.run(
+ [str(fioserver_bin), "--datadir", str(datadir), "pki-init", "--dnsname", "update-server", "--factory", "e2e-factory"],
+ check=True,
+ capture_output=True,
+ )
+ subprocess.run(
+ [str(fioserver_bin), "--datadir", str(datadir), "tuf-init"],
+ check=True,
+ capture_output=True,
+ )
+
+ print("[setup] Starting update-server server (local auth) ...", flush=True)
+ log_path = datadir / "server.log"
+ log_file = open(log_path, "w")
+ proc = subprocess.Popen(
+ [str(fioserver_bin), "serve", "--datadir", str(datadir)],
+ stdout=log_file,
+ stderr=log_file,
+ )
+
+ deadline = time.time() + 30
+ while time.time() < deadline:
+ try:
+ requests.get(f"http://localhost:{SERVER_UI_PORT}", timeout=2)
+ break
+ except requests.exceptions.ConnectionError:
+ time.sleep(1)
+ else:
+ proc.kill()
+ log_file.close()
+ print(log_path.read_text())
+ raise RuntimeError("update-server (local auth) did not start within 30s")
+
+ print(f"[setup] update-server (local auth) running (pid={proc.pid})", flush=True)
+
+ yield datadir
+
+ proc.terminate()
+ proc.wait(timeout=10)
+ log_file.close()
+ if request.session.testsfailed:
+ print("\n[teardown] update-server (local auth) log:\n" + log_path.read_text(), flush=True)
+ shutil.rmtree(datadir, ignore_errors=True)
+
+
def _run_fiocli(fiocli_bin: Path, home: Path, *args) -> str:
try:
result = subprocess.run(
diff --git a/contrib/e2e/test_login.py b/contrib/e2e/test_login.py
new file mode 100644
index 00000000..3f22172a
--- /dev/null
+++ b/contrib/e2e/test_login.py
@@ -0,0 +1,38 @@
+# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries.
+# SPDX-License-Identifier: BSD-3-Clause-Clear
+
+"""Local username/password login flow tests for update-server using Playwright."""
+
+SERVER_URL = "http://localhost:8080"
+
+
+def test_local_login_page_renders(page, update_server_local_auth):
+ """The login page renders with the username/password form visible."""
+ page.goto(f"{SERVER_URL}/devices")
+ assert "Login" in page.title()
+ assert page.locator("#username").is_visible()
+ assert page.locator("#password").is_visible()
+ assert page.get_by_role("button", name="Sign in").is_visible()
+
+
+def test_local_login_invalid_credentials_shows_error(page, update_server_local_auth):
+ """Submitting the wrong password redisplays the form with an error banner."""
+ page.goto(f"{SERVER_URL}/devices")
+ page.fill("#username", "admin")
+ page.fill("#password", "wrong-password")
+ page.get_by_role("button", name="Sign in").click()
+ error = page.locator(".login-error")
+ error.wait_for(state="visible")
+ assert "Invalid username or password" in error.inner_text()
+
+
+def test_local_login_valid_credentials_redirects_to_devices(page, update_server_local_auth):
+ """Submitting valid credentials redirects to /devices with a session cookie set."""
+ page.goto(f"{SERVER_URL}/devices")
+ page.fill("#username", "admin")
+ page.fill("#password", "admin")
+ page.get_by_role("button", name="Sign in").click()
+ page.wait_for_url(f"{SERVER_URL}/devices")
+ assert page.title() == "Devices - Foundries Update Server"
+ cookies = page.context.cookies()
+ assert any(c["name"] == "fioserver-session" for c in cookies)
diff --git a/server/ui/web/templates/base.html b/server/ui/web/templates/base.html
index bbd8f2e8..1a0c472f 100644
--- a/server/ui/web/templates/base.html
+++ b/server/ui/web/templates/base.html
@@ -12,6 +12,9 @@
+ {{/* Keep in sync with the "csrf-fetch-script" block below — duplicated
+ (not called) so standalone pages like local-login.html don't need
+ to wrap in this "header" block just to get the CSRF fetch patch. */}}
+{{end}}
+
{{define "footer"}}