Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3321a70ffd | ||
|
|
859737faec | ||
|
|
fe44740450 | ||
|
|
9b068926a9 |
@@ -0,0 +1,27 @@
|
||||
# Keep the image build context minimal: only api/ and the bundled
|
||||
# custom_components/nestquest/core/ are copied (see the Dockerfile);
|
||||
# everything else — VCS state, virtualenvs, tests, the frontend, and
|
||||
# any local secrets or database files — never enters the build.
|
||||
.git
|
||||
.gitignore
|
||||
.venv
|
||||
**/__pycache__
|
||||
**/*.py[cod]
|
||||
.pytest_cache
|
||||
**/*.db
|
||||
**/*.db-wal
|
||||
**/*.db-shm
|
||||
# Local configuration — never baked into an image.
|
||||
.env
|
||||
**/.env
|
||||
deploy
|
||||
design
|
||||
docs
|
||||
frontend
|
||||
orchestration_tests
|
||||
scripts
|
||||
templates
|
||||
tests
|
||||
uv.lock
|
||||
hacs.json
|
||||
*.md
|
||||
@@ -5,3 +5,12 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
*.metadata.json
|
||||
frontend/node_modules/
|
||||
|
||||
# Feature 17 (API deployment): local secret config and runtime data
|
||||
# are never committed — compose reads deploy/.env; SQLite/WAL sidecar
|
||||
# files are container/volume artifacts.
|
||||
.env
|
||||
**/.env
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Container image for the NestQuest API service (Feature 17).
|
||||
#
|
||||
# Build context is the REPOSITORY ROOT (compose sets it), because the
|
||||
# API loads the bundled domain layer from
|
||||
# custom_components/nestquest/core/ by file location
|
||||
# (api/nestquest_core.py): the image must carry both api/ and the
|
||||
# bundled core at their relative positions. Everything else in the
|
||||
# repository is excluded via .dockerignore.
|
||||
#
|
||||
# Multi-stage so no pip/build toolchain survives into the runtime:
|
||||
# the builder installs the deployable's own manifest
|
||||
# (api/requirements.txt) into a staging prefix and the runtime copies
|
||||
# only the installed packages. The service runs as a non-root user;
|
||||
# the SQLite database lives on the mounted volume at
|
||||
# /var/lib/nestquest (compose supplies NESTQUEST_DB_PATH pointing
|
||||
# there — configuration is environment-supplied, never baked in).
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
COPY api/requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --no-cache-dir --prefix=/install -r /tmp/requirements.txt
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
# The staged dependency install from the builder (no pip, no wheels).
|
||||
COPY --from=builder /install /usr/local
|
||||
|
||||
# /app is the repository-root equivalent: api/ resolves imports as
|
||||
# ``api.*`` and nestquest_core.py finds the bundled core one level up.
|
||||
WORKDIR /app
|
||||
COPY api/ ./api/
|
||||
COPY custom_components/nestquest/core/ ./custom_components/nestquest/core/
|
||||
|
||||
# Non-root runtime user; owns the database volume mount point.
|
||||
RUN useradd --system --create-home --home-dir /home/nestquest nestquest \
|
||||
&& mkdir -p /var/lib/nestquest \
|
||||
&& chown nestquest:nestquest /var/lib/nestquest
|
||||
USER nestquest
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# The database path and the service token / OIDC settings are supplied
|
||||
# by the environment (compose env_file), never committed into the image.
|
||||
ENV NESTQUEST_DB_PATH=/var/lib/nestquest/nestquest.db
|
||||
VOLUME /var/lib/nestquest
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["uvicorn", "--factory", "api.app:create_app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import AppRoutes from "./AppRoutes";
|
||||
import { storeTokens } from "./auth/oidc";
|
||||
import { config } from "./config";
|
||||
@@ -32,6 +32,32 @@ describe("AppRoutes auth gating", () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
function probeCalls(fetchMock: ReturnType<typeof vi.fn>): string[] {
|
||||
return fetchMock.mock.calls
|
||||
.map(([url]) => String(url))
|
||||
.filter((url) => url.includes("/api/"));
|
||||
}
|
||||
|
||||
it("probes exactly GET /api/v1/admin/ping and no non-admin path", async () => {
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<AppRoutes />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
|
||||
});
|
||||
const calls = probeCalls(fetchMock);
|
||||
expect(calls).toEqual([`${config.apiBaseUrl}/api/v1/admin/ping`]);
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(init.method ?? "GET").toBe("GET");
|
||||
expect(init.headers.Authorization).toBe("Bearer at-123");
|
||||
for (const url of calls) {
|
||||
expect(new URL(url, "http://localhost").pathname.startsWith("/api/v1/admin/")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the four-tab shell after a successful authenticated probe", async () => {
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
|
||||
@@ -45,6 +71,9 @@ describe("AppRoutes auth gating", () => {
|
||||
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
|
||||
expect(screen.getByRole("tab", { name: label })).toBeTruthy();
|
||||
}
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
||||
`${config.apiBaseUrl}/api/v1/admin/ping`,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the explicit refusal when the API answers 403", async () => {
|
||||
@@ -58,6 +87,97 @@ describe("AppRoutes auth gating", () => {
|
||||
expect(screen.getByTestId("refusal-screen")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText(/not in the nestquest-admins group/i)).toBeTruthy();
|
||||
expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the login flow when the probe answers 401", async () => {
|
||||
storeTokens({ access_token: "stale", expires_in: 600 });
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal("location", { ...window.location, assign });
|
||||
const fetchMock = vi.fn().mockImplementation(async (url: string) =>
|
||||
String(url).includes("/api/v1/admin/ping")
|
||||
? new Response("", { status: 401 })
|
||||
: jsonResponse({
|
||||
issuer: config.issuer,
|
||||
authorization_endpoint: "https://auth.example.com/authorize/",
|
||||
token_endpoint: "https://auth.example.com/token/",
|
||||
userinfo_endpoint: "https://auth.example.com/userinfo/",
|
||||
end_session_endpoint: "https://auth.example.com/end-session/",
|
||||
jwks_uri: "https://auth.example.com/jwks/",
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<AppRoutes />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("login-screen")).toBeTruthy();
|
||||
});
|
||||
expect(assign).toHaveBeenCalledTimes(1);
|
||||
const redirect = new URL(assign.mock.calls[0][0] as string);
|
||||
expect(redirect.origin + redirect.pathname).toBe("https://auth.example.com/authorize/");
|
||||
expect(probeCalls(fetchMock)).toEqual([`${config.apiBaseUrl}/api/v1/admin/ping`]);
|
||||
expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
|
||||
expect(screen.queryByTestId("probe-error-screen")).toBeNull();
|
||||
});
|
||||
|
||||
for (const status of [404, 500]) {
|
||||
it(`shows the error screen, not the shell, when the probe answers ${status}`, async () => {
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<AppRoutes />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText(new RegExp(`answered ${status}`))).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Sign out" })).toBeTruthy();
|
||||
expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
|
||||
expect(screen.queryByTestId("refusal-screen")).toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
it("shows the error screen, not the shell, when the probe request fails", async () => {
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
const fetchMock = vi.fn().mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<AppRoutes />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText(/could not be reached/i)).toBeTruthy();
|
||||
expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
|
||||
});
|
||||
|
||||
it("re-probes on retry and enters the shell once the probe succeeds", async () => {
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
// After the retry the ping succeeds; the shell's own tab loads stay pending.
|
||||
let pings = 0;
|
||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||
if (!String(url).endsWith("/api/v1/admin/ping")) return new Promise(() => {});
|
||||
pings += 1;
|
||||
return Promise.resolve(new Response("", { status: pings === 1 ? 500 : 200 }));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<AppRoutes />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: "Today" })).toBeTruthy();
|
||||
});
|
||||
expect(String(fetchMock.mock.calls[1][0])).toBe(
|
||||
`${config.apiBaseUrl}/api/v1/admin/ping`,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the configuration error screen when the client id is missing", () => {
|
||||
|
||||
+43
-8
@@ -12,7 +12,7 @@ import {
|
||||
handleCallback,
|
||||
isAuthenticated,
|
||||
} from "./auth/oidc";
|
||||
import { ApiForbiddenError, apiFetch } from "./api/client";
|
||||
import { ApiForbiddenError, ApiUnauthorizedError, apiFetch } from "./api/client";
|
||||
import AdminShell from "./App";
|
||||
|
||||
type Screen =
|
||||
@@ -21,18 +21,28 @@ type Screen =
|
||||
| { kind: "config-error"; message: string }
|
||||
| { kind: "auth-error"; message: string }
|
||||
| { kind: "refusal"; message: string }
|
||||
| { kind: "probe-error"; message: string }
|
||||
| { kind: "app" };
|
||||
|
||||
function currentPath(): string {
|
||||
return window.location.pathname.replace(/\/+$/, "") || "/";
|
||||
}
|
||||
|
||||
const ADMIN_PROBE_PATH = "/api/v1/admin/ping";
|
||||
|
||||
async function probeApi(setScreen: (screen: Screen) => void) {
|
||||
// A cheap authenticated probe so a 403 surfaces as an explicit refusal
|
||||
// right after login instead of a silent empty shell.
|
||||
// The admin plane's authorization probe: only a 2xx enters the shell, so a
|
||||
// non-admin (403) sees the explicit refusal and any other failure an error.
|
||||
try {
|
||||
await apiFetch("/api/v1/children", {}, config);
|
||||
setScreen({ kind: "app" });
|
||||
const response = await apiFetch(ADMIN_PROBE_PATH, {}, config);
|
||||
if (response.ok) {
|
||||
setScreen({ kind: "app" });
|
||||
return;
|
||||
}
|
||||
setScreen({
|
||||
kind: "probe-error",
|
||||
message: `The admin API answered ${response.status}. The admin console cannot load.`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) {
|
||||
setScreen({ kind: "config-error", message: error.message });
|
||||
@@ -42,9 +52,15 @@ async function probeApi(setScreen: (screen: Screen) => void) {
|
||||
setScreen({ kind: "refusal", message: error.message });
|
||||
return;
|
||||
}
|
||||
// Network or other failures still allow the shell to render; API screens
|
||||
// handle their own errors in later tasks.
|
||||
setScreen({ kind: "app" });
|
||||
if (error instanceof ApiUnauthorizedError) {
|
||||
// apiFetch has already started the login redirect.
|
||||
setScreen({ kind: "login" });
|
||||
return;
|
||||
}
|
||||
setScreen({
|
||||
kind: "probe-error",
|
||||
message: "The admin API could not be reached. Check your connection and try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +186,25 @@ export default function AppRoutes() {
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
case "probe-error":
|
||||
return (
|
||||
<div className="admin-error" role="alert" data-testid="probe-error-screen">
|
||||
<h1>Admin console unavailable</h1>
|
||||
<p>{screen.message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setScreen({ kind: "loading" });
|
||||
void probeApi(setScreen);
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<button type="button" onClick={() => { clearAuth(); window.history.replaceState({}, "", "/"); setScreen({ kind: "login" }); }}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
case "app":
|
||||
return <AdminShell />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# NestQuest API
|
||||
|
||||
Standalone FastAPI service owning the NestQuest database (Feature 16).
|
||||
Served with ``uvicorn --factory api.app:create_app``; configuration is
|
||||
environment-supplied (``api/config.py``) and every variable is required.
|
||||
|
||||
## Runtime dependencies
|
||||
|
||||
``api/requirements.txt`` is the API deployable's OWN dependency
|
||||
manifest (fastapi, uvicorn, pyjwt[crypto], httpx) — deliberately kept
|
||||
out of the integration's ``pyproject.toml`` dev group, which exists only
|
||||
so the monorepo test venv can run the API tests.
|
||||
|
||||
## Container deployment (scaffold)
|
||||
|
||||
The image is built from the repository ROOT context (the API loads the
|
||||
bundled domain core from ``custom_components/nestquest/core/`` by file
|
||||
location, so both trees must be in the build context):
|
||||
|
||||
docker build -t nestquest-api:latest .
|
||||
|
||||
Run it with compose — the SQLite file lives on the named volume
|
||||
``nestquest-db`` so recreating the container keeps household data:
|
||||
|
||||
cp deploy/.env.example deploy/.env # then fill in real values
|
||||
docker compose -f deploy/compose.yaml up -d --build
|
||||
|
||||
``deploy/.env`` is git-ignored and supplies the secrets (panel service
|
||||
token, Authentik OIDC settings); ``NESTQUEST_DB_PATH`` is fixed to
|
||||
``/var/lib/nestquest/nestquest.db`` by compose.
|
||||
|
||||
The published port binds ``NESTQUEST_BIND_ADDRESS`` from
|
||||
``deploy/.env`` and defaults to ``127.0.0.1`` (localhost only). In
|
||||
production TLS is terminated by the shared Traefik reverse proxy,
|
||||
which serves ``https://nestquest.cubecraftlabs.com`` (Let's Encrypt,
|
||||
auto-renewed, HTTP redirected to HTTPS) and forwards to the API box
|
||||
on port 8080 — so on that box set ``NESTQUEST_BIND_ADDRESS`` to its
|
||||
LAN address (``10.60.1.14``). Traefik only allows
|
||||
``/api/v1/panel`` from the Home Assistant host. Network separation,
|
||||
backup and restore are later Feature 17 tasks.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Runtime dependency manifest for the NestQuest API deployable.
|
||||
#
|
||||
# The monorepo's pyproject.toml carries fastapi/uvicorn/pyjwt/httpx in
|
||||
# the INTEGRATION's dev group only (so the test venv can exercise the
|
||||
# API); they are deliberately NOT integration runtime dependencies.
|
||||
# Per that manifest's own comment, the API deployable declares its own
|
||||
# runtime dependencies HERE — this file is what the container image
|
||||
# installs (see the Dockerfile at the repository root).
|
||||
#
|
||||
# Compatible ranges mirroring the dev group's floors; the API is a
|
||||
# server, so these are the only packages its runtime needs.
|
||||
fastapi>=0.115
|
||||
uvicorn>=0.30
|
||||
pyjwt[crypto]>=2.9
|
||||
httpx>=0.28
|
||||
@@ -0,0 +1,23 @@
|
||||
# Example environment for the NestQuest API container deployment.
|
||||
#
|
||||
# Copy to ``deploy/.env`` (git-ignored) and fill in REAL values there;
|
||||
# this example carries placeholder names only and is never a source of
|
||||
# secrets. Compose loads ``deploy/.env`` into the api service; the
|
||||
# API fails fast on startup when any of these is unset or empty
|
||||
# (api/config.py — there are no silent defaults).
|
||||
|
||||
# The static panel-plane service token the HA integration presents as
|
||||
# a Bearer credential on /api/v1/panel/* routes. Generate one, e.g.:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
NESTQUEST_PANEL_TOKEN=
|
||||
|
||||
# The Authentik OIDC settings the admin plane verifies JWTs against.
|
||||
NESTQUEST_OIDC_ISSUER=
|
||||
NESTQUEST_OIDC_AUDIENCE=
|
||||
NESTQUEST_OIDC_JWKS_URL=
|
||||
|
||||
# The host interface the API's port 8080 is published on (compose
|
||||
# interpolation, not read by the API). Defaults to 127.0.0.1 when
|
||||
# unset; on the API box set the LAN address the reverse proxy
|
||||
# forwards to, e.g. 10.60.1.14.
|
||||
NESTQUEST_BIND_ADDRESS=
|
||||
@@ -0,0 +1,46 @@
|
||||
# Compose deployment scaffold for the NestQuest API service (Feature
|
||||
# 17). The SQLite database lives on the NAMED VOLUME ``nestquest-db``
|
||||
# mounted at /var/lib/nestquest — recreating the container keeps the
|
||||
# household data (``docker compose down`` leaves named volumes alone).
|
||||
#
|
||||
# Configuration is supplied by the environment: the fixed database
|
||||
# path here, and the secret values (panel service token, Authentik
|
||||
# OIDC settings) from ``deploy/.env`` — a git-ignored file you create
|
||||
# from ``deploy/.env.example`` (placeholder names only; never commit
|
||||
# real values).
|
||||
#
|
||||
# TLS is terminated by the shared Traefik reverse proxy on another
|
||||
# host, which forwards to this service on port 8080. The published
|
||||
# port binds NESTQUEST_BIND_ADDRESS (read from deploy/.env, the
|
||||
# project directory's env file): it defaults to 127.0.0.1 so an
|
||||
# unconfigured deployment is never exposed; set it to the host's LAN
|
||||
# address on the API box so the proxy can reach it.
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
image: nestquest-api:latest
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${NESTQUEST_BIND_ADDRESS:-127.0.0.1}:8080:8080"
|
||||
environment:
|
||||
# The SQLite file inside the named volume; fixed, not a secret.
|
||||
NESTQUEST_DB_PATH: /var/lib/nestquest/nestquest.db
|
||||
env_file:
|
||||
# Secret values (NESTQUEST_PANEL_TOKEN, NESTQUEST_OIDC_*); absent
|
||||
# until you create deploy/.env from the example.
|
||||
- path: .env
|
||||
required: false
|
||||
volumes:
|
||||
- nestquest-db:/var/lib/nestquest
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health')"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
|
||||
volumes:
|
||||
nestquest-db:
|
||||
Reference in New Issue
Block a user