fix(auth): require admin for system backups and storage maintenance (#3036)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-25 15:05:14 +01:00
committed by GitHub
parent 287acab669
commit fc4ac77529
32 changed files with 1467 additions and 133 deletions
@@ -9,14 +9,18 @@ import (
"github.com/gin-gonic/gin"
)
// RegisterRoutes wires the backups endpoints onto g (typically the
// /api/v1/system group).
func RegisterRoutes(g *gin.RouterGroup, svc *Service) {
g.GET("/backups", HandleList(svc))
g.POST("/backups", HandleCreate(svc))
g.GET("/backups/:name/download", HandleDownload(svc))
g.POST("/backups/:name/restore", HandleRestore(svc))
g.DELETE("/backups/:name", HandleDelete(svc))
// RegisterRoutes wires the backups endpoints onto the /api/v1/system groups.
// Only the metadata listing is readable by any authenticated caller. Creating,
// downloading, restoring, and deleting a snapshot act on the whole install:
// a snapshot is a copy of the multi-user database, so the download route is an
// export of every user's data and belongs behind the admin role along with the
// destructive mutations.
func RegisterRoutes(read, admin *gin.RouterGroup, svc *Service) {
read.GET("/backups", HandleList(svc))
admin.POST("/backups", HandleCreate(svc))
admin.GET("/backups/:name/download", HandleDownload(svc))
admin.POST("/backups/:name/restore", HandleRestore(svc))
admin.DELETE("/backups/:name", HandleDelete(svc))
}
// HandleList returns GET /api/v1/system/backups -> { snapshots: [...] }.
@@ -13,14 +13,25 @@ import (
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/kandev/kandev/internal/system/jobs"
)
// newRouter mirrors the production wiring in internal/system: a read group
// plus an admin group guarded by authn.RequireAdmin. The identity is an admin
// (as the synthetic single-user identity is when auth is disabled), so these
// handler tests exercise behavior rather than the role guard, which
// internal/system's route tests own.
func newRouter(svc *Service) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
authn.SetOnGin(c, authn.Identity{UserID: "admin-1", Role: authn.RoleAdmin})
c.Next()
})
g := r.Group("/api/v1/system")
RegisterRoutes(g, svc)
RegisterRoutes(g, g.Group("", authn.RequireAdmin()), svc)
return r
}
@@ -20,9 +20,14 @@ import (
)
// Snapshot is the public representation of a backup file on disk.
// Snapshot is the metadata one backup file exposes over the API. It
// deliberately carries no on-disk path: GET /backups is readable by any
// authenticated user (only the mutating and downloading routes are admin
// only), and the absolute path would disclose the install's data directory to
// all of them. Callers name a snapshot by Name; the service resolves it to a
// path itself via resolveSnapshotPath.
type Snapshot struct {
Name string `json:"name"`
Path string `json:"path"`
SizeBytes int64 `json:"size_bytes"`
ModTime time.Time `json:"mtime"`
Kind string `json:"kind"` // "auto" | "manual"
@@ -121,7 +126,6 @@ func (s *Service) List() ([]Snapshot, error) {
}
out = append(out, Snapshot{
Name: e.Name(),
Path: filepath.Join(dir, e.Name()),
SizeBytes: info.Size(),
ModTime: info.ModTime().UTC(),
Kind: kind,
+18 -13
View File
@@ -107,19 +107,24 @@ func (h *Handler) logError(message string, err error) {
}
}
func RegisterRoutes(group *gin.RouterGroup, handler *Handler) {
group.GET("/storage", handler.getStorage)
group.GET("/storage/disk", handler.getStorageDisk)
group.GET("/storage/settings", handler.getStorageSettings)
group.PATCH("/storage/settings", handler.patchSettings)
group.POST("/storage/go-cache/adopt", handler.adoptGoCache)
group.POST("/storage/analyze", handler.analyze)
group.POST("/storage/run", handler.runNow)
group.GET("/storage/runs", handler.listRuns)
group.GET("/storage/quarantine", handler.listQuarantine)
group.POST("/storage/quarantine/:id/restore", handler.restoreQuarantine)
group.DELETE("/storage/quarantine", handler.deleteQuarantineBulk)
group.DELETE("/storage/quarantine/:id", handler.deleteQuarantine)
// RegisterRoutes wires storage maintenance onto the /api/v1/system groups.
// Reading the current usage, policy, run history, and quarantine contents is
// open to any authenticated caller; every route that changes install-wide
// state (settings, adoption, cleanup passes, quarantine restore/purge)
// requires the admin role.
func RegisterRoutes(read, admin *gin.RouterGroup, handler *Handler) {
read.GET("/storage", handler.getStorage)
read.GET("/storage/disk", handler.getStorageDisk)
read.GET("/storage/settings", handler.getStorageSettings)
read.GET("/storage/runs", handler.listRuns)
read.GET("/storage/quarantine", handler.listQuarantine)
admin.PATCH("/storage/settings", handler.patchSettings)
admin.POST("/storage/go-cache/adopt", handler.adoptGoCache)
admin.POST("/storage/analyze", handler.analyze)
admin.POST("/storage/run", handler.runNow)
admin.POST("/storage/quarantine/:id/restore", handler.restoreQuarantine)
admin.DELETE("/storage/quarantine", handler.deleteQuarantineBulk)
admin.DELETE("/storage/quarantine/:id", handler.deleteQuarantine)
}
func (h *Handler) getStorageDisk(c *gin.Context) {
@@ -12,13 +12,28 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/auth/authn"
)
// newTestRouter mirrors the production wiring in internal/system: a read group
// plus an admin group guarded by authn.RequireAdmin. The identity is an admin
// (as the synthetic single-user identity is when auth is disabled), so these
// handler tests exercise behavior rather than the role guard, which
// internal/system's route tests own.
func newTestRouter(handler *Handler) *gin.Engine {
router := gin.New()
router.Use(func(c *gin.Context) {
authn.SetOnGin(c, authn.Identity{UserID: "admin-1", Role: authn.RoleAdmin})
c.Next()
})
read := router.Group("/api/v1/system")
RegisterRoutes(read, read.Group("", authn.RequireAdmin()), handler)
return router
}
func TestPatchSettingsHidesInternalSaveFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
group := router.Group("/api/v1/system")
RegisterRoutes(group, NewHandler(HandlerConfig{
router := newTestRouter(NewHandler(HandlerConfig{
Settings: failingSettingsManager{err: errors.New("database credentials leaked")},
}))
body, err := json.Marshal(map[string]any{"settings": DefaultSettings()})
@@ -43,8 +58,7 @@ func TestPatchSettingsHidesInternalSaveFailure(t *testing.T) {
func TestGetStorageReturnsSnapshotAnalyzedAt(t *testing.T) {
gin.SetMode(gin.TestMode)
analyzedAt := time.Date(2026, time.July, 23, 12, 0, 0, 0, time.UTC)
router := gin.New()
RegisterRoutes(router.Group("/api/v1/system"), NewHandler(HandlerConfig{
router := newTestRouter(NewHandler(HandlerConfig{
Settings: staticSettingsManager{}, Runs: staticRunLister{},
Overview: staticCachedOverview{snapshot: OverviewSnapshot{
Summary: Summary{Workspaces: map[string]any{"bytes": 42}}, AnalyzedAt: analyzedAt,
@@ -73,8 +87,7 @@ func TestGetStorageReturnsSnapshotAnalyzedAt(t *testing.T) {
func TestGetStorageDiskReturnsIndependentCapacityResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
RegisterRoutes(router.Group("/api/v1/system"), NewHandler(HandlerConfig{}))
router := newTestRouter(NewHandler(HandlerConfig{}))
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/system/storage/disk", nil))
@@ -95,8 +108,7 @@ func TestGetStorageDiskReturnsIndependentCapacityResponse(t *testing.T) {
func TestGetStorageDiskReturnsMeasuredFields(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
RegisterRoutes(router.Group("/api/v1/system"), NewHandler(HandlerConfig{
router := newTestRouter(NewHandler(HandlerConfig{
DiskPath: "/data",
DiskCapacity: func(context.Context, string) (DiskCapacity, error) {
return DiskCapacity{
@@ -126,8 +138,7 @@ func TestGetStorageDiskReturnsMeasuredFields(t *testing.T) {
func TestGetStorageDiskLogsReaderErrorsAndReturnsUnavailable(t *testing.T) {
gin.SetMode(gin.TestMode)
var loggedMessage string
router := gin.New()
RegisterRoutes(router.Group("/api/v1/system"), NewHandler(HandlerConfig{
router := newTestRouter(NewHandler(HandlerConfig{
DiskPath: "/data",
DiskCapacity: func(context.Context, string) (DiskCapacity, error) {
return DiskCapacity{}, errors.New("statfs failed")
@@ -164,8 +175,7 @@ func TestGetStorageSettingsReturnsPolicyWithoutOverviewScan(t *testing.T) {
HostGlobalDockerCleanup: true,
}
overview := &recordingOverviewReader{capabilities: capabilities}
router := gin.New()
RegisterRoutes(router.Group("/api/v1/system"), NewHandler(HandlerConfig{
router := newTestRouter(NewHandler(HandlerConfig{
Settings: staticSettingsManager{settings: settings},
Overview: overview,
}))
@@ -205,8 +215,7 @@ func TestGetStorageSettingsHidesInternalLoadFailure(t *testing.T) {
internalErr := errors.New("database credentials leaked")
var loggedMessage string
var loggedErr error
router := gin.New()
RegisterRoutes(router.Group("/api/v1/system"), NewHandler(HandlerConfig{
router := newTestRouter(NewHandler(HandlerConfig{
Settings: failingSettingsManager{getErr: internalErr},
LogError: func(message string, err error) {
loggedMessage, loggedErr = message, err
@@ -231,8 +240,7 @@ func TestGetStorageSettingsHidesInternalLoadFailure(t *testing.T) {
func TestDeleteQuarantineBulkValidatesConfirmation(t *testing.T) {
gin.SetMode(gin.TestMode)
mutations := &recordingMutations{}
router := gin.New()
RegisterRoutes(router.Group("/api/v1/system"), NewHandler(HandlerConfig{Mutations: mutations}))
router := newTestRouter(NewHandler(HandlerConfig{Mutations: mutations}))
for _, test := range []struct {
body string
@@ -169,7 +169,9 @@ func newStorageRoutesTestRouter(t *testing.T) *gin.Engine {
func newStorageRoutesTestRouterWithMutations(t *testing.T, mutations storage.Mutations) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
// The synthetic identity is what httpmw injects while auth is disabled, so
// this router reproduces single-user mode exactly.
router := systemRouterForSyntheticIdentity()
connection, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal(err)
+2 -2
View File
@@ -185,7 +185,7 @@ func (s *Service) RegisterRoutes(router *gin.Engine, log *logger.Logger) {
g.GET("/info", info.Handler(s.Info))
if s.Storage != nil {
storage.RegisterRoutes(g, s.Storage)
storage.RegisterRoutes(g, admin, s.Storage)
}
g.GET("/disk-usage", disk.HandleGet(s.Disk))
@@ -197,7 +197,7 @@ func (s *Service) RegisterRoutes(router *gin.Engine, log *logger.Logger) {
admin.POST("/database/optimize", database.HandleOptimize(s.Database))
admin.POST("/database/reset", database.HandleReset(s.Database))
backups.RegisterRoutes(g, s.Backups)
backups.RegisterRoutes(g, admin, s.Backups)
if s.FrontendErrors != nil {
g.POST("/logs/frontend-errors", frontenderrors.Handle(s.FrontendErrors))
@@ -0,0 +1,426 @@
package system
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/kandev/kandev/internal/auth/httpmw"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/system/backups"
"github.com/kandev/kandev/internal/system/jobs"
systemsettings "github.com/kandev/kandev/internal/system/settings"
"github.com/kandev/kandev/internal/system/storage"
_ "github.com/mattn/go-sqlite3"
"go.uber.org/zap"
)
// The backups snapshot planted on disk by newBackupsAuthzFixture. Its bytes
// double as the leak canary for the download route.
const (
authzSnapshotName = "manual-20260101-000000.db"
authzSnapshotContent = "every-users-private-rows"
authzBackupsPath = "/api/v1/system/backups"
authzSnapshotPath = authzBackupsPath + "/" + authzSnapshotName
authzStoragePath = "/api/v1/system/storage"
authzJSONContentType = "application/json"
)
// systemRouterForSyntheticIdentity mirrors the auth-disabled request path:
// httpmw injects the synthetic single-user admin on every request.
func systemRouterForSyntheticIdentity() *gin.Engine {
router := gin.New()
router.Use(func(ctx *gin.Context) {
authn.SetOnGin(ctx, httpmw.SyntheticIdentity())
ctx.Next()
})
return router
}
type backupsAuthzFixture struct {
service *backups.Service
tracker *jobs.Tracker
backupsDir string
}
func newBackupsAuthzFixture(t *testing.T) *backupsAuthzFixture {
t.Helper()
gin.SetMode(gin.TestMode)
dataDir := t.TempDir()
databasePath := filepath.Join(dataDir, "kandev.db")
connection, err := sqlx.Open("sqlite3", databasePath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = connection.Close() })
if _, err := connection.Exec(`CREATE TABLE things (id TEXT PRIMARY KEY)`); err != nil {
t.Fatal(err)
}
backupsDir := filepath.Join(dataDir, "backups")
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
t.Fatal(err)
}
snapshot := filepath.Join(backupsDir, authzSnapshotName)
if err := os.WriteFile(snapshot, []byte(authzSnapshotContent), 0o644); err != nil {
t.Fatal(err)
}
tracker := jobs.NewTracker(nil, testLoggerForAuthz(t))
service := backups.NewService(databasePath, db.NewPool(connection, connection), tracker, testLoggerForAuthz(t))
return &backupsAuthzFixture{service: service, tracker: tracker, backupsDir: backupsDir}
}
func (f *backupsAuthzFixture) router(t *testing.T, router *gin.Engine) *gin.Engine {
t.Helper()
(&Service{Backups: f.service}).RegisterRoutes(router, testLoggerForAuthz(t))
return router
}
func (f *backupsAuthzFixture) snapshotExists() bool {
_, err := os.Stat(filepath.Join(f.backupsDir, authzSnapshotName))
return err == nil
}
func testLoggerForAuthz(t *testing.T) *logger.Logger {
t.Helper()
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
t.Fatal(err)
}
return log
}
// validStorageSettingsBody is a complete, in-range settings payload, so a
// non-403 response can only mean the request reached the handler.
func validStorageSettingsBody(t *testing.T) string {
t.Helper()
body, err := json.Marshal(map[string]any{"settings": storage.DefaultSettings()})
if err != nil {
t.Fatal(err)
}
return string(body)
}
func newRequest(method, path, body string) *http.Request {
request := httptest.NewRequest(method, path, strings.NewReader(body))
request.Header.Set("Content-Type", authzJSONContentType)
return request
}
func serve(router *gin.Engine, request *http.Request) *httptest.ResponseRecorder {
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
// TestBackupsMutatingRoutesDenyMembers locks the authorization fix: with auth
// enabled a member may not export, create, restore, or delete an install-wide
// snapshot. Each case also asserts the service never ran.
func TestBackupsMutatingRoutesDenyMembers(t *testing.T) {
fixture := newBackupsAuthzFixture(t)
router := fixture.router(t, systemRouterForRole(authn.RoleMember))
download := serve(router, newRequest(http.MethodGet, authzSnapshotPath+"/download", ""))
if download.Code != http.StatusForbidden {
t.Fatalf("member download status = %d, want 403; body=%s", download.Code, download.Body.String())
}
if strings.Contains(download.Body.String(), authzSnapshotContent) {
t.Fatalf("member download leaked snapshot bytes: %s", download.Body.String())
}
create := serve(router, newRequest(http.MethodPost, authzBackupsPath, "{}"))
if create.Code != http.StatusForbidden {
t.Fatalf("member create status = %d, want 403; body=%s", create.Code, create.Body.String())
}
if jobsStarted := len(fixture.tracker.List()); jobsStarted != 0 {
t.Fatalf("member create started %d jobs, want 0", jobsStarted)
}
restore := serve(router, newRequest(http.MethodPost, authzSnapshotPath+"/restore", `{"confirm":"RESTORE"}`))
if restore.Code != http.StatusForbidden {
t.Fatalf("member restore status = %d, want 403; body=%s", restore.Code, restore.Body.String())
}
if jobsStarted := len(fixture.tracker.List()); jobsStarted != 0 {
t.Fatalf("member restore started %d jobs, want 0", jobsStarted)
}
remove := serve(router, newRequest(http.MethodDelete, authzSnapshotPath, ""))
if remove.Code != http.StatusForbidden {
t.Fatalf("member delete status = %d, want 403; body=%s", remove.Code, remove.Body.String())
}
if !fixture.snapshotExists() {
t.Fatal("member delete removed the snapshot despite the 403")
}
}
// TestBackupsListStaysReadableForMembers keeps the metadata-only listing on
// the non-admin group so the page still renders for members.
func TestBackupsListStaysReadableForMembers(t *testing.T) {
fixture := newBackupsAuthzFixture(t)
router := fixture.router(t, systemRouterForRole(authn.RoleMember))
list := serve(router, newRequest(http.MethodGet, authzBackupsPath, ""))
if list.Code != http.StatusOK {
t.Fatalf("member list status = %d, want 200; body=%s", list.Code, list.Body.String())
}
if !strings.Contains(list.Body.String(), authzSnapshotName) {
t.Fatalf("member list omitted the snapshot: %s", list.Body.String())
}
}
// TestBackupsRoutesUnchangedForAdminAndSyntheticIdentity proves the guard is a
// no-op for admins and for the auth-disabled synthetic single user.
func TestBackupsRoutesUnchangedForAdminAndSyntheticIdentity(t *testing.T) {
for name, build := range map[string]func() *gin.Engine{
"admin": func() *gin.Engine { return systemRouterForRole(authn.RoleAdmin) },
"synthetic": systemRouterForSyntheticIdentity,
} {
t.Run(name, func(t *testing.T) {
fixture := newBackupsAuthzFixture(t)
router := fixture.router(t, build())
download := serve(router, newRequest(http.MethodGet, authzSnapshotPath+"/download", ""))
if download.Code != http.StatusOK || download.Body.String() != authzSnapshotContent {
t.Fatalf("download status = %d body = %q", download.Code, download.Body.String())
}
create := serve(router, newRequest(http.MethodPost, authzBackupsPath, "{}"))
if create.Code != http.StatusAccepted {
t.Fatalf("create status = %d, want 202; body=%s", create.Code, create.Body.String())
}
restore := serve(router, newRequest(http.MethodPost, authzSnapshotPath+"/restore", `{"confirm":"NOPE"}`))
if restore.Code != http.StatusBadRequest {
t.Fatalf("restore status = %d, want 400 (confirmation rejected, not authorization)", restore.Code)
}
remove := serve(router, newRequest(http.MethodDelete, authzSnapshotPath, ""))
if remove.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, want 204; body=%s", remove.Code, remove.Body.String())
}
if fixture.snapshotExists() {
t.Fatal("delete returned 204 but left the snapshot on disk")
}
if started := len(fixture.tracker.List()); started == 0 {
t.Fatal("create never reached the service")
}
})
}
}
type storageAuthzCase struct {
name string
method string
path string
body string
}
// storageMutatingRoutes is every storage endpoint that changes install-wide
// state. Bodies carry valid confirmations so a missing guard produces a
// non-403 status rather than an incidental 400.
func storageMutatingRoutes() []storageAuthzCase {
return []storageAuthzCase{
{name: "adopt go cache", method: http.MethodPost, path: authzStoragePath + "/go-cache/adopt", body: `{"path":"/tmp/cache","confirm":"ADOPT"}`},
{name: "analyze", method: http.MethodPost, path: authzStoragePath + "/analyze", body: "{}"},
{name: "run cleanup", method: http.MethodPost, path: authzStoragePath + "/run", body: "{}"},
{name: "restore quarantine entry", method: http.MethodPost, path: authzStoragePath + "/quarantine/entry-1/restore", body: "{}"},
{name: "delete quarantine entry", method: http.MethodDelete, path: authzStoragePath + "/quarantine/entry-1", body: `{"confirm":"DELETE"}`},
{name: "purge quarantine", method: http.MethodDelete, path: authzStoragePath + "/quarantine", body: `{"scope":"eligible","confirm":"DELETE ELIGIBLE"}`},
}
}
func storageReadRoutes() []string {
return []string{
authzStoragePath,
authzStoragePath + "/disk",
authzStoragePath + "/settings",
authzStoragePath + "/runs",
authzStoragePath + "/quarantine",
}
}
// recordingStorageMutations records which destructive operation ran so a
// blocked request can be distinguished from one that merely returned an error.
type recordingStorageMutations struct {
calls []string
}
func (r *recordingStorageMutations) AdoptGoCache(context.Context, string, string) (storage.StorageMaintenanceSettings, storage.Capabilities, error) {
r.calls = append(r.calls, "adopt")
return storage.DefaultSettings(), storage.Capabilities{}, nil
}
func (r *recordingStorageMutations) Analyze(context.Context) (string, error) {
r.calls = append(r.calls, "analyze")
return "analysis-job", nil
}
func (r *recordingStorageMutations) RunNow(context.Context, []string, bool) (string, error) {
r.calls = append(r.calls, "run")
return "cleanup-job", nil
}
func (r *recordingStorageMutations) RestoreQuarantine(context.Context, string) (storage.QuarantineEntry, error) {
r.calls = append(r.calls, "restore")
return storage.QuarantineEntry{}, nil
}
func (r *recordingStorageMutations) DeleteQuarantine(context.Context, string, string) (string, error) {
r.calls = append(r.calls, "delete")
return "delete-job", nil
}
func (r *recordingStorageMutations) PurgeQuarantine(context.Context, storage.QuarantinePurgeScope, string) (string, error) {
r.calls = append(r.calls, "purge")
return "purge-job", nil
}
func newStorageAuthzRouter(t *testing.T, router *gin.Engine, mutations storage.Mutations) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
connection, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal(err)
}
connection.SetMaxOpenConns(1)
t.Cleanup(func() { _ = connection.Close() })
pool := db.NewPool(connection, connection)
rawSettings, err := systemsettings.NewStore(pool)
if err != nil {
t.Fatal(err)
}
storageStore, err := storage.NewStore(pool)
if err != nil {
t.Fatal(err)
}
service := &Service{Storage: storage.NewHandler(storage.HandlerConfig{
Settings: storage.NewSettingsStore(rawSettings), Runs: storageStore,
Quarantine: storageStore, Overview: emptyStorageOverview{}, Mutations: mutations,
})}
service.RegisterRoutes(router, testLoggerForAuthz(t))
return router
}
// TestStorageMutatingRoutesDenyMembers locks the second half of the fix:
// members cannot run install-wide maintenance, and the operation never fires.
func TestStorageMutatingRoutesDenyMembers(t *testing.T) {
for _, testCase := range storageMutatingRoutes() {
t.Run(testCase.name, func(t *testing.T) {
mutations := &recordingStorageMutations{}
router := newStorageAuthzRouter(t, systemRouterForRole(authn.RoleMember), mutations)
response := serve(router, newRequest(testCase.method, testCase.path, testCase.body))
if response.Code != http.StatusForbidden {
t.Fatalf("member status = %d, want 403; body=%s", response.Code, response.Body.String())
}
if len(mutations.calls) != 0 {
t.Fatalf("member request invoked %v despite the 403", mutations.calls)
}
})
}
}
// TestStorageSettingsPatchDeniesMembers covers the settings write, which goes
// through the settings store rather than Mutations.
func TestStorageSettingsPatchDeniesMembers(t *testing.T) {
router := newStorageAuthzRouter(t, systemRouterForRole(authn.RoleMember), &recordingStorageMutations{})
response := serve(router, newRequest(http.MethodPatch, authzStoragePath+"/settings", validStorageSettingsBody(t)))
if response.Code != http.StatusForbidden {
t.Fatalf("member PATCH status = %d, want 403; body=%s", response.Code, response.Body.String())
}
}
// TestStorageReadRoutesStayOpenToMembers keeps the read-only maintenance view
// on the non-admin group.
func TestStorageReadRoutesStayOpenToMembers(t *testing.T) {
router := newStorageAuthzRouter(t, systemRouterForRole(authn.RoleMember), &recordingStorageMutations{})
for _, path := range storageReadRoutes() {
t.Run(path, func(t *testing.T) {
response := serve(router, newRequest(http.MethodGet, path, ""))
if response.Code != http.StatusOK {
t.Fatalf("member GET status = %d, want 200; body=%s", response.Code, response.Body.String())
}
})
}
}
// TestStorageMutatingRoutesUnchangedForAdminAndSyntheticIdentity proves the
// guard changes nothing for admins or for the auth-disabled single user.
func TestStorageMutatingRoutesUnchangedForAdminAndSyntheticIdentity(t *testing.T) {
for name, build := range map[string]func() *gin.Engine{
"admin": func() *gin.Engine { return systemRouterForRole(authn.RoleAdmin) },
"synthetic": systemRouterForSyntheticIdentity,
} {
t.Run(name, func(t *testing.T) {
for _, testCase := range storageMutatingRoutes() {
t.Run(testCase.name, func(t *testing.T) {
mutations := &recordingStorageMutations{}
router := newStorageAuthzRouter(t, build(), mutations)
response := serve(router, newRequest(testCase.method, testCase.path, testCase.body))
if response.Code == http.StatusForbidden || response.Code == http.StatusUnauthorized {
t.Fatalf("status = %d, want the route to run; body=%s", response.Code, response.Body.String())
}
if len(mutations.calls) != 1 {
t.Fatalf("calls = %v, want exactly one", mutations.calls)
}
})
}
patch := serve(
newStorageAuthzRouter(t, build(), &recordingStorageMutations{}),
newRequest(http.MethodPatch, authzStoragePath+"/settings", validStorageSettingsBody(t)),
)
if patch.Code == http.StatusForbidden || patch.Code == http.StatusUnauthorized {
t.Fatalf("PATCH settings status = %d, want the route to run; body=%s", patch.Code, patch.Body.String())
}
})
}
}
// TestBackupsListLeaksOnlyMetadata keeps GET /backups on the non-admin group
// honest. The listing is the one backups route a member may call, so it must
// carry snapshot metadata and nothing else. The absolute on-disk path is not
// metadata: it discloses the install's data directory (usually including the
// operating account's home directory) to every authenticated user, and the UI
// deliberately shows only a "<data-dir>/backups/" placeholder instead.
func TestBackupsListLeaksOnlyMetadata(t *testing.T) {
fixture := newBackupsAuthzFixture(t)
router := fixture.router(t, systemRouterForRole(authn.RoleMember))
list := serve(router, newRequest(http.MethodGet, authzBackupsPath, ""))
if list.Code != http.StatusOK {
t.Fatalf("member list status = %d, want 200; body=%s", list.Code, list.Body.String())
}
var payload struct {
Snapshots []map[string]any `json:"snapshots"`
}
if err := json.Unmarshal(list.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if len(payload.Snapshots) != 1 {
t.Fatalf("snapshots = %v, want exactly the seeded one", payload.Snapshots)
}
fields := make([]string, 0, len(payload.Snapshots[0]))
for field := range payload.Snapshots[0] {
fields = append(fields, field)
}
sort.Strings(fields)
want := []string{"kind", "mtime", "name", "size_bytes"}
if !slices.Equal(fields, want) {
t.Fatalf("snapshot fields = %v, want %v", fields, want)
}
if strings.Contains(list.Body.String(), fixture.backupsDir) {
t.Fatalf("listing disclosed the backups directory: %s", list.Body.String())
}
}
@@ -0,0 +1,119 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SnapshotInfo } from "@/lib/types/system";
const mocks = vi.hoisted(() => ({
useBackups: vi.fn(),
}));
// Mirrors the auth slice the admin gate reads. `undefined` is the
// auth-disabled single-user mode, which the backend treats as an admin.
let currentRole: "admin" | "member" | undefined;
let currentMode: "disabled" | "setup" | "enabled" = "enabled";
// Mirrors the auth slice the admin gate reads. `currentMode` distinguishes
// auth-disabled single-user mode (synthetic admin) from a cleared session.
vi.mock("@/components/state-provider", () => ({
useAppStore: (
selector: (state: { auth: { mode: string; user?: { role: string } } }) => unknown,
) =>
selector({
auth: { mode: currentMode, user: currentRole ? { role: currentRole } : undefined },
}),
}));
vi.mock("@/hooks/domains/system/use-backups", () => ({
useBackups: mocks.useBackups,
}));
vi.mock("./job-progress-indicator", () => ({
JobProgressIndicator: () => null,
}));
vi.mock("./restore-dialog", () => ({
RestoreDialog: () => null,
}));
import { BackupsTable } from "./backups-table";
const SNAPSHOT: SnapshotInfo = {
name: "manual-20260101-000000.db",
kind: "manual",
size_bytes: 2048,
mtime: "2026-01-01T00:00:00Z",
};
const CREATE_TEST_ID = "system-backups-create";
const DOWNLOAD_TEST_ID = "system-backups-download";
const RESTORE_TEST_ID = "system-backups-restore";
const DELETE_TEST_ID = "system-backups-delete";
const NAME_TEST_ID = "system-backups-name";
function headerCellCount(): number {
return screen.getAllByRole("columnheader").length;
}
function bodyCellCount(): number {
return screen.getByTestId("system-backups-row").querySelectorAll("td").length;
}
describe("BackupsTable", () => {
afterEach(cleanup);
beforeEach(() => {
mocks.useBackups.mockReturnValue({
backups: [SNAPSHOT],
loaded: true,
isLoading: false,
reload: vi.fn(),
});
});
// Create, download, restore, and delete are admin-only on the backend
// because a snapshot is a copy of the whole multi-user database. A member
// must not be offered a control that can only answer 403.
it("hides every mutating control from a member but keeps the listing", () => {
currentRole = "member";
currentMode = "enabled";
render(<BackupsTable />);
expect(screen.queryByTestId(CREATE_TEST_ID)).toBeNull();
expect(screen.queryByTestId(DOWNLOAD_TEST_ID)).toBeNull();
expect(screen.queryByTestId(RESTORE_TEST_ID)).toBeNull();
expect(screen.queryByTestId(DELETE_TEST_ID)).toBeNull();
expect(screen.getByTestId(NAME_TEST_ID).textContent).toBe(SNAPSHOT.name);
expect(screen.getByTestId("system-backups-admin-only")).toBeTruthy();
// Header and body must agree: an empty action cell with no header leaves
// an unlabeled column for assistive technology.
expect(headerCellCount()).toBe(bodyCellCount());
});
it("offers every control to an admin", () => {
currentRole = "admin";
currentMode = "enabled";
render(<BackupsTable />);
expect(screen.getByTestId(CREATE_TEST_ID)).toBeTruthy();
expect(screen.getByTestId(DOWNLOAD_TEST_ID)).toBeTruthy();
expect(screen.getByTestId(RESTORE_TEST_ID)).toBeTruthy();
expect(screen.getByTestId(DELETE_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId("system-backups-admin-only")).toBeNull();
expect(headerCellCount()).toBe(bodyCellCount());
});
// Auth disabled: no user in the boot payload, and the backend's synthetic
// identity is an admin. Nothing may change.
it("offers every control when no user is signed in", () => {
currentRole = undefined;
currentMode = "disabled";
render(<BackupsTable />);
expect(screen.getByTestId(CREATE_TEST_ID)).toBeTruthy();
expect(screen.getByTestId(DOWNLOAD_TEST_ID)).toBeTruthy();
expect(screen.getByTestId(RESTORE_TEST_ID)).toBeTruthy();
expect(screen.getByTestId(DELETE_TEST_ID)).toBeTruthy();
});
});
@@ -9,6 +9,7 @@ import { Badge } from "@kandev/ui/badge";
import { Spinner } from "@kandev/ui/spinner";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
import { IconDownload, IconTrash, IconArchive, IconRotateClockwise } from "@tabler/icons-react";
import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin";
import { useBackups } from "@/hooks/domains/system/use-backups";
import { buildBackupDownloadUrl, createBackup, deleteBackup } from "@/lib/api/domains/system-api";
import { formatDateTime } from "@/lib/i18n/formats";
@@ -45,7 +46,7 @@ const KIND_LABEL_KEYS: Record<SnapshotKind, string> = {
manual: "system:backupsKindManual",
};
function BackupRow({
function BackupRowActions({
row,
onRestore,
onDelete,
@@ -53,6 +54,61 @@ function BackupRow({
row: SnapshotInfo;
onRestore: (name: string) => void;
onDelete: (name: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-end gap-1">
<Button
asChild
size="sm"
variant="ghost"
className="cursor-pointer"
data-testid="system-backups-download"
>
{/* These three are icon-only, so the aria-label is their only
accessible name. It names the snapshot it acts on. */}
<a
href={buildBackupDownloadUrl(row.name)}
download
aria-label={t("system:backupsDownloadLabel", { name: row.name })}
>
<IconDownload className="h-3.5 w-3.5" />
</a>
</Button>
<Button
size="sm"
variant="ghost"
className="cursor-pointer"
onClick={() => onRestore(row.name)}
aria-label={t("system:backupsRestoreLabel", { name: row.name })}
data-testid="system-backups-restore"
>
<IconRotateClockwise className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="cursor-pointer text-destructive"
onClick={() => onDelete(row.name)}
aria-label={t("system:backupsDeleteLabel", { name: row.name })}
data-testid="system-backups-delete"
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</div>
);
}
function BackupRow({
row,
isAdmin,
onRestore,
onDelete,
}: {
row: SnapshotInfo;
isAdmin: boolean;
onRestore: (name: string) => void;
onDelete: (name: string) => void;
}) {
const { t } = useTranslation();
return (
@@ -68,57 +124,27 @@ function BackupRow({
</TableCell>
<TableCell className="text-xs text-right">{formatBytes(row.size_bytes)}</TableCell>
<TableCell className="text-xs">{formatTimestamp(row.mtime)}</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
<Button
asChild
size="sm"
variant="ghost"
className="cursor-pointer"
data-testid="system-backups-download"
>
{/* These three are icon-only, so the aria-label is their only
accessible name. It names the snapshot it acts on. */}
<a
href={buildBackupDownloadUrl(row.name)}
download
aria-label={t("system:backupsDownloadLabel", { name: row.name })}
>
<IconDownload className="h-3.5 w-3.5" />
</a>
</Button>
<Button
size="sm"
variant="ghost"
className="cursor-pointer"
onClick={() => onRestore(row.name)}
aria-label={t("system:backupsRestoreLabel", { name: row.name })}
data-testid="system-backups-restore"
>
<IconRotateClockwise className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="cursor-pointer text-destructive"
onClick={() => onDelete(row.name)}
aria-label={t("system:backupsDeleteLabel", { name: row.name })}
data-testid="system-backups-delete"
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
{/* The whole cell goes, not just its contents: the Actions header is
conditional too, and an empty fifth cell would leave the table with
four headers and five cells: an unlabeled column for assistive
technology and dead width on a phone. */}
{isAdmin && (
<TableCell>
<BackupRowActions row={row} onRestore={onRestore} onDelete={onDelete} />
</TableCell>
)}
</TableRow>
);
}
function BackupsList({
items,
isAdmin,
onRestore,
onDelete,
}: {
items: SnapshotInfo[];
isAdmin: boolean;
onRestore: (name: string) => void;
onDelete: (name: string) => void;
}) {
@@ -131,12 +157,20 @@ function BackupsList({
<TableHead>{t("system:backupsColumnKind")}</TableHead>
<TableHead className="text-right">{t("system:backupsColumnSize")}</TableHead>
<TableHead>{t("system:backupsColumnCreated")}</TableHead>
<TableHead className="text-right">{t("system:backupsColumnActions")}</TableHead>
{isAdmin && (
<TableHead className="text-right">{t("system:backupsColumnActions")}</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{items.map((row) => (
<BackupRow key={row.name} row={row} onRestore={onRestore} onDelete={onDelete} />
<BackupRow
key={row.name}
row={row}
isAdmin={isAdmin}
onRestore={onRestore}
onDelete={onDelete}
/>
))}
</TableBody>
</Table>
@@ -168,6 +202,10 @@ async function waitForCreatedBackup(
export function BackupsTable() {
const { t } = useTranslation();
const { backups, loaded, isLoading, reload } = useBackups();
// Creating, downloading, restoring, and deleting a snapshot are admin-only
// on the backend: a snapshot is a copy of the whole multi-user database.
// Members keep the read-only listing.
const isAdmin = useIsAdmin();
const [creating, setCreating] = useState(false);
const [restoreName, setRestoreName] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -207,15 +245,17 @@ export function BackupsTable() {
</span>
}
actions={
<Button
size="sm"
disabled={creating}
onClick={() => void onCreate()}
className={settingsActionClassName("cursor-pointer")}
data-testid="system-backups-create"
>
{creating ? t("system:backupsCreating") : t("system:backupsCreate")}
</Button>
isAdmin && (
<Button
size="sm"
disabled={creating}
onClick={() => void onCreate()}
className={settingsActionClassName("cursor-pointer")}
data-testid="system-backups-create"
>
{creating ? t("system:backupsCreating") : t("system:backupsCreate")}
</Button>
)
}
/>
<CardContent className="space-y-4">
@@ -223,6 +263,11 @@ export function BackupsTable() {
{t("system:backupsHelp")}{" "}
{t("system:backupsRetention", { count: BACKUP_RETENTION_COUNT })}
</SettingsFieldDescription>
{!isAdmin && (
<SettingsFieldDescription data-testid="system-backups-admin-only">
{t("system:backupsAdminOnly")}
</SettingsFieldDescription>
)}
{error && <SettingsErrorText data-testid="system-backups-error">{error}</SettingsErrorText>}
{!loaded && isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -237,6 +282,7 @@ export function BackupsTable() {
{items.length > 0 && (
<BackupsList
items={items}
isAdmin={isAdmin}
onRestore={(n) => setRestoreName(n)}
onDelete={(n) => void onDelete(n)}
/>
@@ -9,13 +9,22 @@ type Props = {
setPath: (path: string) => void;
onOpen: () => void;
pending: boolean;
/** Overrides the default pending explanation, e.g. for the admin gate. */
pendingReason?: string;
enabled: boolean;
};
export function StorageAdoptionField({ path, setPath, onOpen, pending, enabled }: Props) {
export function StorageAdoptionField({
path,
setPath,
onOpen,
pending,
pendingReason,
enabled,
}: Props) {
const { t } = useTranslation();
let disabledReason: string | undefined;
if (pending) disabledReason = t("system:storageActionPending");
if (pending) disabledReason = pendingReason ?? t("system:storageActionPending");
else if (!enabled) disabledReason = t("system:storageEnableGoCacheFirst");
else if (!path.trim()) disabledReason = t("system:storageEnterCachePathFirst");
const fieldLabel = t("system:storageExternalGoCache");
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
policyBlockedReason,
policyControlsPending,
storageActionDisabledReason,
} from "./storage-gating";
const t = (key: string) => key;
const ADMIN_ONLY = "system:storageAdminOnly";
const ACTION_PENDING = "system:storageActionPending";
describe("storageActionDisabledReason", () => {
it("blocks every maintenance action for a non-admin", () => {
expect(storageActionDisabledReason(t, null, false)).toBe(ADMIN_ONLY);
});
it("reports the admin gate ahead of a pending action", () => {
expect(storageActionDisabledReason(t, "save", false)).toBe(ADMIN_ONLY);
});
it("keeps the pending reason for an admin", () => {
expect(storageActionDisabledReason(t, "save", true)).toBe(ACTION_PENDING);
});
it("leaves an idle admin unblocked", () => {
expect(storageActionDisabledReason(t, null, true)).toBeUndefined();
});
});
describe("policyBlockedReason", () => {
it("blocks saving the policy for a non-admin", () => {
expect(policyBlockedReason(t, null, false, false)).toBe(ADMIN_ONLY);
});
it("blocks a non-admin even while the policy is still loading", () => {
expect(policyBlockedReason(t, null, true, false)).toBe(ADMIN_ONLY);
});
it("keeps the adoption-pending reason for an admin", () => {
expect(policyBlockedReason(t, "adopt", false, true)).toBe("system:storageAdoptionPending");
});
it("keeps the loading reason for an admin", () => {
expect(policyBlockedReason(t, null, true, true)).toBe("system:storagePolicyLoadingBlock");
});
it("leaves a ready admin unblocked", () => {
expect(policyBlockedReason(t, null, false, true)).toBeUndefined();
});
});
describe("policyControlsPending", () => {
it("locks the policy controls while a save or adoption is in flight", () => {
expect(policyControlsPending("save", undefined)).toBe(true);
expect(policyControlsPending("adopt", undefined)).toBe(true);
});
it("leaves the controls live for an idle admin", () => {
expect(policyControlsPending(null, undefined)).toBe(false);
expect(policyControlsPending("analyze", undefined)).toBe(false);
});
it("locks the controls whenever a read-only reason applies", () => {
expect(policyControlsPending(null, ADMIN_ONLY)).toBe(true);
});
});
@@ -0,0 +1,46 @@
import type { StoragePendingAction } from "@/hooks/domains/system/use-storage-maintenance";
type Translate = (key: string) => string;
/**
* Why the storage maintenance actions (analyze, cleanup, quarantine
* restore/purge) are unavailable, or `undefined` when they are.
*
* The admin gate wins over the transient pending reason: those routes are
* mounted on the admin group, so for a member they are permanently
* unavailable rather than momentarily busy.
*/
export function storageActionDisabledReason(
t: Translate,
action: StoragePendingAction,
isAdmin: boolean,
): string | undefined {
if (!isAdmin) return t("system:storageAdminOnly");
if (action) return t("system:storageActionPending");
return undefined;
}
/** Why the storage policy form cannot be edited or saved, if it cannot be. */
export function policyBlockedReason(
t: Translate,
action: StoragePendingAction,
loading: boolean,
isAdmin: boolean,
): string | undefined {
if (!isAdmin) return t("system:storageAdminOnly");
if (action === "adopt") return t("system:storageAdoptionPending");
if (loading) return t("system:storagePolicyLoadingBlock");
return undefined;
}
/**
* Whether the storage policy form's controls are locked: either an action
* that rewrites the policy is already in flight, or the caller may not edit
* it at all (see `policyBlockedReason`).
*/
export function policyControlsPending(
action: StoragePendingAction,
readOnlyReason: string | undefined,
): boolean {
return action === "save" || action === "adopt" || Boolean(readOnlyReason);
}
@@ -13,6 +13,22 @@ const mocks = vi.hoisted(() => ({
const IDLE_PERIOD_TEST_ID = "storage-idle-period";
const SAVE_BUTTON_NAME = "Save changes";
const ANALYZE_TEST_ID = "storage-analyze";
const RUN_NOW_TEST_ID = "storage-run-now";
// Mirrors the auth slice the admin gate reads. `undefined` is the
// auth-disabled single-user mode, which the backend treats as an admin.
let currentRole: "admin" | "member" | undefined;
let currentMode: "disabled" | "setup" | "enabled" = "enabled";
// Mirrors the auth slice the admin gate reads. `currentMode` distinguishes
// auth-disabled single-user mode (synthetic admin) from a cleared session.
vi.mock("@/components/state-provider", () => ({
useAppStore: (
selector: (state: { auth: { mode: string; user?: { role: string } } }) => unknown,
) =>
selector({
auth: { mode: currentMode, user: currentRole ? { role: currentRole } : undefined },
}),
}));
vi.mock("@/hooks/domains/system/use-storage-maintenance", async (importOriginal) => ({
...(await importOriginal<typeof import("@/hooks/domains/system/use-storage-maintenance")>()),
@@ -112,10 +128,49 @@ describe("StorageMaintenanceSettings", () => {
afterEach(cleanup);
beforeEach(() => {
currentRole = "admin";
currentMode = "enabled";
mocks.useSystemJob.mockReturnValue(undefined);
mocks.useStorageMaintenance.mockReturnValue(controller(overview));
});
// Every mutating storage route is admin-only on the backend, so a member's
// controls must be disabled here rather than 403 after the click.
it("disables the maintenance actions and the policy form for a member", () => {
currentRole = "member";
currentMode = "enabled";
const editableOverview = { ...overview, settings: { ...overview.settings, enabled: true } };
mocks.useStorageMaintenance.mockReturnValue(controller(editableOverview));
render(<StorageMaintenanceSettings />, { wrapper: Providers });
expect((screen.getByTestId(ANALYZE_TEST_ID) as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByTestId(RUN_NOW_TEST_ID) as HTMLButtonElement).disabled).toBe(true);
const idlePeriod = screen.getByTestId(IDLE_PERIOD_TEST_ID) as HTMLInputElement;
expect(idlePeriod.disabled).toBe(true);
// A disabled field cannot go dirty, so the shared Save control stays
// inert: a member has no way to reach the admin-only PATCH at all.
fireEvent.change(idlePeriod, { target: { value: "31" } });
expect(
(screen.getByRole("button", { name: SAVE_BUTTON_NAME }) as HTMLButtonElement).disabled,
).toBe(true);
});
// Auth disabled: no user in the boot payload, and the backend's synthetic
// identity is an admin. Nothing may change.
it("leaves every control live when no user is signed in", () => {
currentRole = undefined;
currentMode = "disabled";
const editableOverview = { ...overview, settings: { ...overview.settings, enabled: true } };
mocks.useStorageMaintenance.mockReturnValue(controller(editableOverview));
render(<StorageMaintenanceSettings />, { wrapper: Providers });
expect((screen.getByTestId(ANALYZE_TEST_ID) as HTMLButtonElement).disabled).toBe(false);
expect((screen.getByTestId(RUN_NOW_TEST_ID) as HTMLButtonElement).disabled).toBe(false);
expect((screen.getByTestId(IDLE_PERIOD_TEST_ID) as HTMLInputElement).disabled).toBe(false);
});
it("shows analysis completion inside the Analyze button", () => {
const analysisJob = {
id: "analysis-1",
@@ -231,6 +286,10 @@ describe("StorageMaintenanceSettings busy feedback", () => {
afterEach(cleanup);
beforeEach(() => {
// Reset the role explicitly: without it these tests inherit whatever the
// previous describe block left behind and pass only in file order.
currentRole = undefined;
currentMode = "disabled";
mocks.useSystemJob.mockReturnValue(undefined);
});
@@ -256,11 +315,38 @@ describe("StorageMaintenanceSettings busy feedback", () => {
fireEvent.click(screen.getByRole("button", { name: "Run anyway" }));
expect(runAnyway).toHaveBeenCalledTimes(1);
});
// Run anyway forces POST /storage/run, which is admin only. The busy alert
// is a second, independent path to that call: gating the action buttons
// above it is not enough.
it("does not expose Run anyway to a member even when force is available", () => {
currentRole = "member";
currentMode = "enabled";
const runAnyway = vi.fn();
mocks.useStorageMaintenance.mockReturnValue({
...controller(overview),
busy: {
resources: [{ kind: "execution_running", label: "An agent execution is running" }],
forceAvailable: true,
},
runAnyway,
});
render(<StorageMaintenanceSettings />, { wrapper: Providers });
expect(screen.queryByTestId("storage-run-anyway")).toBeNull();
expect(runAnyway).not.toHaveBeenCalled();
});
});
describe("StorageMaintenanceSettings pending policy", () => {
afterEach(cleanup);
beforeEach(() => {
currentRole = undefined;
currentMode = "disabled";
});
it("rebases an adopted Go cache path into a dirty policy draft before shared save", async () => {
const currentController = controller(overview);
mocks.useStorageMaintenance.mockReturnValue(currentController);
@@ -361,6 +447,8 @@ describe("StorageMaintenanceSettings coordinated save", () => {
afterEach(cleanup);
beforeEach(() => {
currentRole = undefined;
currentMode = "disabled";
mocks.useSystemJob.mockReturnValue(undefined);
});
@@ -12,7 +12,13 @@ import {
} from "@/hooks/domains/system/use-storage-maintenance";
import type { StorageMaintenanceSettings as Settings, SystemJob } from "@/lib/types/system";
import { useSettingsSaveContributor } from "../../settings-save-provider";
import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin";
import { StorageActionButton } from "./storage-action-button";
import {
policyBlockedReason,
policyControlsPending,
storageActionDisabledReason,
} from "./storage-gating";
import { StorageDiskCapacityCard } from "./storage-disk-capacity-card";
import { StorageOverviewCard } from "./storage-overview-card";
import { StoragePolicyCard } from "./storage-policy-card";
@@ -135,13 +141,19 @@ function StorageActions({
function StorageActionFeedback({
controller,
isAdmin,
}: {
controller: ReturnType<typeof useStorageMaintenance>;
isAdmin: boolean;
}) {
const { t } = useTranslation();
if (controller.busy) {
return (
<StorageBusyFeedback busy={controller.busy} onRunAnyway={() => void controller.runAnyway()} />
<StorageBusyFeedback
busy={controller.busy}
isAdmin={isAdmin}
onRunAnyway={() => void controller.runAnyway()}
/>
);
}
if (!controller.error) return null;
@@ -156,9 +168,11 @@ function StorageActionFeedback({
function StorageBusyFeedback({
busy,
isAdmin,
onRunAnyway,
}: {
busy: StorageBusyState;
isAdmin: boolean;
onRunAnyway: () => void;
}) {
const { t } = useTranslation();
@@ -174,7 +188,10 @@ function StorageBusyFeedback({
<li key={resource.kind}>{resource.label}</li>
))}
</ul>
{busy.forceAvailable && (
{/* Run anyway forces POST /storage/run, which is admin only. The busy
alert is a second, independent path to that call, so it carries the
same gate as the action buttons rather than trusting them. */}
{busy.forceAvailable && isAdmin && (
<>
<p className="mt-3">{t("system:storageBusyForceHint")}</p>
<StorageActionButton
@@ -196,29 +213,10 @@ function serializeSettings(settings: Settings | null): string {
return settings ? JSON.stringify(settings) : "loading";
}
function policyPendingAction(action: ReturnType<typeof useStorageMaintenance>["pendingAction"]) {
return action === "save" || action === "adopt";
}
function policyBlockedReason(
t: (key: string) => string,
action: ReturnType<typeof useStorageMaintenance>["pendingAction"],
loading: boolean,
function useStoragePolicyDraft(
controller: ReturnType<typeof useStorageMaintenance>,
isAdmin: boolean,
) {
if (action === "adopt") return t("system:storageAdoptionPending");
if (loading) return t("system:storagePolicyLoadingBlock");
return undefined;
}
function storageActionDisabledReason(
t: (key: string) => string,
action: ReturnType<typeof useStorageMaintenance>["pendingAction"],
) {
if (action) return t("system:storageActionPending");
return undefined;
}
function useStoragePolicyDraft(controller: ReturnType<typeof useStorageMaintenance>) {
const { t } = useTranslation();
const [draft, setDraft] = useState<Settings | null>(null);
const previousServerSettings = useRef<Settings | null>(null);
@@ -240,7 +238,7 @@ function useStoragePolicyDraft(controller: ReturnType<typeof useStorageMaintenan
previousServerSettings.current = savedSettings;
}, [savedSettings]);
const invalidReason = policyBlockedReason(t, controller.pendingAction, policyLoading);
const invalidReason = policyBlockedReason(t, controller.pendingAction, policyLoading, isAdmin);
useSettingsSaveContributor({
id: "system:storage-policy",
revision: serializeSettings(draft),
@@ -283,6 +281,7 @@ function StoragePolicyState({ loading, error }: { loading: boolean; error?: stri
function StoragePrimarySections({
controller,
disabledReason,
readOnlyReason,
draft,
setDraft,
savedSettings,
@@ -290,12 +289,13 @@ function StoragePrimarySections({
}: {
controller: ReturnType<typeof useStorageMaintenance>;
disabledReason?: string;
readOnlyReason?: string;
draft: Settings | null;
setDraft: (settings: Settings | null) => void;
savedSettings: Settings | null;
onRunTemporaryArtifacts: () => void;
}) {
const controlsPending = policyPendingAction(controller.pendingAction);
const controlsPending = policyControlsPending(controller.pendingAction, readOnlyReason);
const policyLoading = controller.loading?.policy ?? !savedSettings;
const capabilities = controller.policy?.capabilities ?? controller.overview?.capabilities;
return (
@@ -321,6 +321,7 @@ function StoragePrimarySections({
savedSettings={savedSettings}
capabilities={capabilities}
pending={controlsPending}
pendingReason={readOnlyReason}
onChange={setDraft}
onAdopt={controller.adopt}
onCleanDependencies={() => void controller.runNow(["workspace_dependencies"])}
@@ -362,18 +363,23 @@ function StorageQuarantineSection({
function StoragePageSections({
controller,
disabledReason,
readOnlyReason,
isAdmin,
onRunTemporaryArtifacts,
}: {
controller: ReturnType<typeof useStorageMaintenance>;
disabledReason?: string;
readOnlyReason?: string;
isAdmin: boolean;
onRunTemporaryArtifacts: () => void;
}) {
const { draft, setDraft, savedSettings } = useStoragePolicyDraft(controller);
const { draft, setDraft, savedSettings } = useStoragePolicyDraft(controller, isAdmin);
return (
<>
<StoragePrimarySections
controller={controller}
disabledReason={disabledReason}
readOnlyReason={readOnlyReason}
draft={draft}
setDraft={setDraft}
savedSettings={savedSettings}
@@ -396,18 +402,25 @@ function StoragePageSections({
export function StorageMaintenanceSettings() {
const { t } = useTranslation();
const controller = useStorageMaintenance();
const actionDisabledReason = storageActionDisabledReason(t, controller.pendingAction);
const isAdmin = useIsAdmin();
// Every mutating storage route is admin-only on the backend, so a member
// sees the read-only view with each control disabled and explained rather
// than a 403 after the click.
const readOnlyReason = isAdmin ? undefined : t("system:storageAdminOnly");
const actionDisabledReason = storageActionDisabledReason(t, controller.pendingAction, isAdmin);
const [temporaryCleanupOpen, setTemporaryCleanupOpen] = useState(false);
return (
<div className="min-w-0 space-y-6" data-testid="storage-settings-page">
<StorageActions controller={controller} disabledReason={actionDisabledReason} />
<StorageActionFeedback controller={controller} />
<StorageActionFeedback controller={controller} isAdmin={isAdmin} />
<StoragePageSections
controller={controller}
disabledReason={actionDisabledReason}
readOnlyReason={readOnlyReason}
isAdmin={isAdmin}
onRunTemporaryArtifacts={() => setTemporaryCleanupOpen(true)}
/>
<TemporaryArtifactsDialog
@@ -17,6 +17,11 @@ type Props = {
savedSettings: StorageMaintenanceSettings;
capabilities: StorageCapabilities;
pending: boolean;
/**
* Why the controls are disabled, when it is not the default "an action is
* already running". The admin gate uses it so a member sees the real reason.
*/
pendingReason?: string;
onChange: (settings: StorageMaintenanceSettings) => void;
onAdopt: (path: string) => Promise<void>;
onCleanDependencies?: () => void;
@@ -24,7 +29,7 @@ type Props = {
type PolicySectionProps = Pick<
Props,
"settings" | "savedSettings" | "capabilities" | "onChange" | "pending"
"settings" | "savedSettings" | "capabilities" | "onChange" | "pending" | "pendingReason"
>;
function settingIsDirty<T>(
@@ -98,6 +103,7 @@ function WorkspaceSection({
settings,
savedSettings,
pending,
pendingReason,
onChange,
onCleanDependencies,
}: PolicySectionProps & Pick<Props, "onCleanDependencies">) {
@@ -158,6 +164,7 @@ function WorkspaceSection({
settings={settings}
savedSettings={savedSettings}
pending={pending}
pendingReason={pendingReason}
onChange={onChange}
onCleanDependencies={onCleanDependencies}
/>
@@ -184,6 +191,7 @@ function GoCacheSection({
savedSettings,
capabilities,
pending,
pendingReason,
onChange,
adoptionPath,
setAdoptionPath,
@@ -248,6 +256,7 @@ function GoCacheSection({
path={adoptionPath}
setPath={setAdoptionPath}
pending={pending}
pendingReason={pendingReason}
enabled={settings.go_cache.enabled}
onOpen={onOpenAdoption}
/>
@@ -381,6 +390,7 @@ function DockerSection({
savedSettings,
capabilities,
pending,
pendingReason,
onChange,
onOpenDedicated,
}: PolicySectionProps & { onOpenDedicated: () => void }) {
@@ -393,7 +403,7 @@ function DockerSection({
? undefined
: t("system:storageDockerUnavailable");
const disabledReason =
(pending ? t("system:storageActionPending") : undefined) ??
(pending ? (pendingReason ?? t("system:storageActionPending")) : undefined) ??
unavailable ??
(!settings.docker.dedicated_daemon_acknowledged
? t("system:storageAcknowledgeDedicatedFirst")
@@ -483,6 +493,7 @@ export function StoragePolicyCard({
savedSettings,
capabilities,
pending,
pendingReason,
onChange,
onAdopt,
onCleanDependencies,
@@ -516,6 +527,7 @@ export function StoragePolicyCard({
savedSettings={savedSettings}
capabilities={capabilities}
pending={pending}
pendingReason={pendingReason}
onChange={onChange}
/>
<WorkspaceSection
@@ -523,6 +535,7 @@ export function StoragePolicyCard({
savedSettings={savedSettings}
capabilities={capabilities}
pending={pending}
pendingReason={pendingReason}
onChange={onChange}
onCleanDependencies={onCleanDependencies}
/>
@@ -531,6 +544,7 @@ export function StoragePolicyCard({
savedSettings={savedSettings}
capabilities={capabilities}
pending={pending}
pendingReason={pendingReason}
onChange={onChange}
adoptionPath={adoptionPath}
setAdoptionPath={setAdoptionPath}
@@ -541,6 +555,7 @@ export function StoragePolicyCard({
savedSettings={savedSettings}
capabilities={capabilities}
pending={pending}
pendingReason={pendingReason}
onChange={onChange}
onOpenDedicated={() => setDockerDialogOpen(true)}
/>
@@ -549,6 +564,7 @@ export function StoragePolicyCard({
savedSettings={savedSettings}
capabilities={capabilities}
pending={pending}
pendingReason={pendingReason}
onChange={onChange}
/>
</div>
@@ -26,6 +26,8 @@ type Props = {
settings: StorageMaintenanceSettings;
savedSettings: StorageMaintenanceSettings;
pending: boolean;
/** Overrides the default pending explanation, e.g. for the admin gate. */
pendingReason?: string;
onChange: (settings: StorageMaintenanceSettings) => void;
onCleanDependencies?: () => void;
};
@@ -34,6 +36,7 @@ export function StorageWorkspaceDependencySettings({
settings,
savedSettings,
pending,
pendingReason,
onChange,
onCleanDependencies,
}: Props) {
@@ -43,7 +46,7 @@ export function StorageWorkspaceDependencySettings({
const dependencyCleanupDirty = dependenciesEnabled !== savedDependenciesEnabled;
let dependencyActionDisabledReason: string | undefined;
if (pending) {
dependencyActionDisabledReason = t("system:storageActionPending");
dependencyActionDisabledReason = pendingReason ?? t("system:storageActionPending");
} else if (!savedDependenciesEnabled) {
dependencyActionDisabledReason = t("system:storageEnableWorkspaceDependenciesAndSave");
}
@@ -0,0 +1,154 @@
import { expect, type APIRequestContext, type BrowserContext } from "@playwright/test";
/**
* Shared setup and API probes for the Data & storage member-gating specs
* (system-data-storage-member-gating.spec.ts desktop and its mobile-* twin).
*
* The backend mounts system backups and storage maintenance on two route
* groups: reads are open to any authenticated caller, mutations and the
* snapshot download require the admin role. These helpers exercise that split
* over the real HTTP stack with a real member session, so the specs can spend
* their assertions on the rendered gating.
*/
export const GATING_ADMIN = {
email: "admin@demo.dev",
password: "adminpass123",
displayName: "Ada Admin",
};
export const GATING_MEMBER = {
email: "sam@demo.dev",
password: "memberpass123",
displayName: "Sam Member",
};
export const DATA_STORAGE_ROUTE = "/settings/system/data-storage";
/** Creates the member account from an authenticated admin context. */
export async function createMember(adminContext: BrowserContext, baseUrl: string): Promise<void> {
const res = await adminContext.request.post(`${baseUrl}/api/v1/users`, {
data: {
email: GATING_MEMBER.email,
password: GATING_MEMBER.password,
display_name: GATING_MEMBER.displayName,
role: "member",
},
});
expect(res.status(), await res.text()).toBe(201);
}
type Snapshot = { name: string; kind: string };
/**
* Creates a manual snapshot as the admin and resolves its filename. Create is
* asynchronous (202 + job id), so poll the listing until the file lands: the
* member specs need a real row to prove the listing survives the gate.
*/
export async function createSnapshotAsAdmin(
adminContext: BrowserContext,
baseUrl: string,
): Promise<string> {
const created = await adminContext.request.post(`${baseUrl}/api/v1/system/backups`);
expect(created.status(), await created.text()).toBe(202);
let name = "";
await expect
.poll(
async () => {
const snapshots = await listSnapshots(adminContext.request, baseUrl);
const manual = snapshots.find((snapshot) => snapshot.kind === "manual");
name = manual?.name ?? "";
return name;
},
{ timeout: 20_000 },
)
.not.toBe("");
return name;
}
export async function listSnapshots(
request: APIRequestContext,
baseUrl: string,
): Promise<Snapshot[]> {
const res = await request.get(`${baseUrl}/api/v1/system/backups`);
expect(res.status(), await res.text()).toBe(200);
const body = (await res.json()) as { snapshots?: Snapshot[] };
return body.snapshots ?? [];
}
/**
* Asserts the backend refuses every mutating and exporting route for a member
* while leaving the reads open. This is the security contract itself; the
* rendered gating in the specs only keeps a member from meeting a 403.
*/
export async function expectMemberApiGating(
memberContext: BrowserContext,
baseUrl: string,
snapshotName: string,
): Promise<void> {
const api = memberContext.request;
const system = `${baseUrl}/api/v1/system`;
for (const path of [
"/backups",
"/storage",
"/storage/disk",
"/storage/settings",
"/storage/runs",
"/storage/quarantine",
]) {
const res = await api.get(`${system}${path}`);
expect(res.status(), `GET ${path}: ${await res.text()}`).toBe(200);
}
// The download is the most serious of these: a snapshot is a byte copy of
// every user's rows, so a member must never receive one.
const download = await api.get(`${system}/backups/${snapshotName}/download`);
expect(download.status(), await download.text()).toBe(403);
const denials: Array<[string, Promise<{ status(): number; text(): Promise<string> }>]> = [
["POST /backups", api.post(`${system}/backups`)],
[
"POST /backups/:name/restore",
api.post(`${system}/backups/${snapshotName}/restore`, { data: { confirm: "RESTORE" } }),
],
["DELETE /backups/:name", api.delete(`${system}/backups/${snapshotName}`)],
["POST /storage/analyze", api.post(`${system}/storage/analyze`, { data: {} })],
["POST /storage/run", api.post(`${system}/storage/run`, { data: {} })],
[
"POST /storage/go-cache/adopt",
api.post(`${system}/storage/go-cache/adopt`, { data: { path: "/tmp/c", confirm: "ADOPT" } }),
],
[
"POST /storage/quarantine/:id/restore",
api.post(`${system}/storage/quarantine/entry-1/restore`, { data: {} }),
],
[
"DELETE /storage/quarantine/:id",
api.delete(`${system}/storage/quarantine/entry-1`, { data: { confirm: "DELETE" } }),
],
[
"DELETE /storage/quarantine",
api.delete(`${system}/storage/quarantine`, {
data: { scope: "eligible", confirm: "DELETE ELIGIBLE" },
}),
],
];
for (const [label, pending] of denials) {
const res = await pending;
expect(res.status(), `${label}: ${await res.text()}`).toBe(403);
}
// PATCH needs a valid body so a 403 cannot be mistaken for a 400.
const settings = await api.get(`${system}/storage/settings`);
const currentSettings = ((await settings.json()) as { settings: unknown }).settings;
const patch = await api.patch(`${system}/storage/settings`, {
data: { settings: currentSettings },
});
expect(patch.status(), await patch.text()).toBe(403);
// The snapshot must still be there after every attempt above.
const remaining = await listSnapshots(api, baseUrl);
expect(remaining.map((snapshot) => snapshot.name)).toContain(snapshotName);
}
@@ -0,0 +1,127 @@
import { devices, expect } from "@playwright/test";
import path from "node:path";
import { backendFixture as test } from "../../fixtures/backend";
import { login, setupAdmin } from "../../helpers/auth";
import {
createMember,
createSnapshotAsAdmin,
DATA_STORAGE_ROUTE,
expectMemberApiGating,
GATING_ADMIN,
GATING_MEMBER,
} from "./data-storage-gating-helpers";
/**
* Mobile parity for the Data & storage member gate. The gating adds no new
* composition: it removes controls from the backups table and disables the
* storage actions inside surfaces that already have shipped phone layouts
* (see tests/system/mobile-storage-maintenance.spec.ts). What is
* viewport-specific is the consequence: dropping the backups Actions column
* changes the table's column count, and a table is the surface most likely to
* push a phone into horizontal scroll. This spec proves the member view is
* complete, inert, and contained at Pixel 5 width.
*
* Runs in the `mobile-chrome` project (routed away from the desktop `auth`
* project via its testIgnore) and restarts the worker backend with auth on
* and its own database, mirroring system-data-storage-member-gating.spec.ts.
* afterAll restarts to the fixture baseline.
*/
test.describe.serial("Data & storage member gating (mobile)", () => {
let snapshotName = "";
test.beforeAll(async ({ backend, browser }) => {
await backend.restart({
KANDEV_FEATURES_AUTH: "true",
KANDEV_DATABASE_PATH: path.join(backend.tmpDir, "kandev-mobile-data-storage.db"),
});
const adminContext = await browser.newContext({
...devices["Pixel 5"],
baseURL: backend.frontendUrl,
});
await setupAdmin(adminContext, backend.baseUrl, GATING_ADMIN);
await login(adminContext, backend.baseUrl, GATING_ADMIN);
await createMember(adminContext, backend.baseUrl);
snapshotName = await createSnapshotAsAdmin(adminContext, backend.baseUrl);
await adminContext.close();
});
test.afterAll(async ({ backend }) => {
await backend.restart();
});
test("a member gets the read-only view, inert and contained, on a phone", async ({
browser,
backend,
}) => {
// Manual contexts do not inherit the project's device options; spread
// them explicitly so this spec actually exercises the mobile viewport.
const ctx = await browser.newContext({
...devices["Pixel 5"],
baseURL: backend.frontendUrl,
});
await login(ctx, backend.baseUrl, GATING_MEMBER);
await expectMemberApiGating(ctx, backend.baseUrl, snapshotName);
const page = await ctx.newPage();
expect((await page.viewportSize())?.width).toBe(393);
await page.goto(DATA_STORAGE_ROUTE);
await expect(page.getByTestId("system-backups-name").first()).toHaveText(snapshotName, {
timeout: 15_000,
});
await expect(page.getByTestId("system-backups-admin-only")).toBeVisible();
await expect(page.getByTestId("system-backups-create")).toHaveCount(0);
await expect(page.getByTestId("system-backups-download")).toHaveCount(0);
await expect(page.getByTestId("system-backups-restore")).toHaveCount(0);
await expect(page.getByTestId("system-backups-delete")).toHaveCount(0);
const analyze = page.getByTestId("storage-analyze");
await expect(analyze).toBeDisabled();
await expect(page.getByTestId("storage-run-now")).toBeDisabled();
// The reason must stay reachable by touch rather than hover-only: the
// disabled control keeps a touch-sized target so its tooltip trigger is
// usable on a phone.
const analyzeBox = await analyze.boundingBox();
expect(analyzeBox).not.toBeNull();
expect(analyzeBox!.height).toBeGreaterThanOrEqual(44);
// The narrower table must not push the document into horizontal scroll.
await expect
.poll(() =>
page.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
)
.toBe(true);
await ctx.close();
});
test("an admin still gets every control on a phone", async ({ browser, backend }) => {
const ctx = await browser.newContext({
...devices["Pixel 5"],
baseURL: backend.frontendUrl,
});
await login(ctx, backend.baseUrl, GATING_ADMIN);
const page = await ctx.newPage();
expect((await page.viewportSize())?.width).toBe(393);
await page.goto(DATA_STORAGE_ROUTE);
await expect(page.getByTestId("system-backups-create")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("system-backups-delete").first()).toBeVisible();
await expect(page.getByTestId("system-backups-admin-only")).toHaveCount(0);
await expect(page.getByTestId("storage-analyze")).toBeEnabled();
await expect
.poll(() =>
page.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
)
.toBe(true);
await ctx.close();
});
});
@@ -0,0 +1,95 @@
import { expect } from "@playwright/test";
import path from "node:path";
import { backendFixture as test } from "../../fixtures/backend";
import { login, setupAdmin } from "../../helpers/auth";
import {
createMember,
createSnapshotAsAdmin,
DATA_STORAGE_ROUTE,
expectMemberApiGating,
GATING_ADMIN,
GATING_MEMBER,
} from "./data-storage-gating-helpers";
/**
* System backups and storage maintenance act on the whole install: a snapshot
* is a byte copy of the multi-user database, and a cleanup pass rewrites
* shared state. Both are admin only on the backend, so Settings > System >
* Data & storage must not offer a member controls that can only answer 403.
*
* Runs in the `auth` project (backend restarted with auth required). Serial:
* the two tests share the admin, member, and snapshot created in beforeAll.
* A per-file database keeps the single-shot auth setup from colliding with
* the other auth specs sharing the worker; afterAll restarts to baseline.
*/
test.describe.serial("Data & storage member gating", () => {
let snapshotName = "";
test.beforeAll(async ({ backend, browser }) => {
await backend.restart({
KANDEV_FEATURES_AUTH: "true",
KANDEV_DATABASE_PATH: path.join(backend.tmpDir, "kandev-auth-data-storage.db"),
});
const adminContext = await browser.newContext({ baseURL: backend.frontendUrl });
await setupAdmin(adminContext, backend.baseUrl, GATING_ADMIN);
await login(adminContext, backend.baseUrl, GATING_ADMIN);
await createMember(adminContext, backend.baseUrl);
snapshotName = await createSnapshotAsAdmin(adminContext, backend.baseUrl);
await adminContext.close();
});
test.afterAll(async ({ backend }) => {
await backend.restart();
});
test("a member keeps the read-only view and no mutating control", async ({
browser,
backend,
}) => {
const ctx = await browser.newContext({ baseURL: backend.frontendUrl });
await login(ctx, backend.baseUrl, GATING_MEMBER);
await expectMemberApiGating(ctx, backend.baseUrl, snapshotName);
const page = await ctx.newPage();
await page.goto(DATA_STORAGE_ROUTE);
// The listing survives the gate: the member still sees the snapshot's
// name, size and timestamp, which is all the read route now carries.
const row = page.locator('[data-testid="system-backups-row"]', {
has: page.locator(`[data-testid="system-backups-name"]`),
});
await expect(row.first()).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("system-backups-name").first()).toHaveText(snapshotName);
await expect(page.getByTestId("system-backups-admin-only")).toBeVisible();
// Every backups control that answers 403 is gone, not merely disabled.
await expect(page.getByTestId("system-backups-create")).toHaveCount(0);
await expect(page.getByTestId("system-backups-download")).toHaveCount(0);
await expect(page.getByTestId("system-backups-restore")).toHaveCount(0);
await expect(page.getByTestId("system-backups-delete")).toHaveCount(0);
// Storage keeps its controls visible but inert, with the reason attached.
await expect(page.getByTestId("storage-analyze")).toBeDisabled();
await expect(page.getByTestId("storage-run-now")).toBeDisabled();
await ctx.close();
});
test("an admin still gets every control on the same page", async ({ browser, backend }) => {
const ctx = await browser.newContext({ baseURL: backend.frontendUrl });
await login(ctx, backend.baseUrl, GATING_ADMIN);
const page = await ctx.newPage();
await page.goto(DATA_STORAGE_ROUTE);
await expect(page.getByTestId("system-backups-create")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("system-backups-download").first()).toBeVisible();
await expect(page.getByTestId("system-backups-restore").first()).toBeVisible();
await expect(page.getByTestId("system-backups-delete").first()).toBeVisible();
await expect(page.getByTestId("system-backups-admin-only")).toHaveCount(0);
await expect(page.getByTestId("storage-analyze")).toBeEnabled();
await expect(page.getByTestId("storage-run-now")).toBeEnabled();
await ctx.close();
});
});
@@ -0,0 +1,11 @@
"use client";
import { useAppStore } from "@/components/state-provider";
import { isAdminIdentity } from "@/lib/auth/is-admin";
/** Whether the current caller may use admin-only, install-wide controls. */
export function useIsAdmin(): boolean {
const mode = useAppStore((state) => state.auth.mode);
const role = useAppStore((state) => state.auth.user?.role);
return isAdminIdentity(mode, role);
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { isAdminIdentity } from "./is-admin";
describe("isAdminIdentity", () => {
it("treats an admin role as admin while auth is enabled", () => {
expect(isAdminIdentity("enabled", "admin")).toBe(true);
});
it("treats a member role as not admin", () => {
expect(isAdminIdentity("enabled", "member")).toBe(false);
});
// Auth disabled: the boot payload carries no user, and the backend's
// synthetic single-user identity is an admin. This is the case that must
// keep every install-wide control available in single-user mode.
it("treats the auth-disabled single user as admin", () => {
expect(isAdminIdentity("disabled", undefined)).toBe(true);
});
// A backend old enough to send no auth block at all hydrates the slice
// defaults, which are mode "disabled".
it("treats a missing auth block as the disabled single user", () => {
expect(isAdminIdentity(undefined, undefined)).toBe(true);
});
// Session expiry calls clearAuthenticated(), which nulls the user but
// leaves mode "enabled" while the redirect to /login is in flight. Reading
// that as admin would flash install-wide controls at a signed-out caller.
it("does not treat a cleared session as admin", () => {
expect(isAdminIdentity("enabled", undefined)).toBe(false);
});
it("does not treat setup mode as admin", () => {
expect(isAdminIdentity("setup", undefined)).toBe(false);
});
it("treats an unknown role as not admin", () => {
expect(isAdminIdentity("enabled", "guest")).toBe(false);
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { AuthMode } from "@/lib/state/slices/auth/types";
/**
* Role gate for install-wide controls, mirroring `authn.RequireAdmin` on the
* backend.
*
* Mode is read alongside the role because "no user" means two different
* things. With authentication disabled the boot payload carries no user and
* the backend injects a synthetic *admin* identity on every request, so the
* UI must match or turning auth off would hide the controls it is meant to
* leave untouched. With authentication enabled, "no user" instead means the
* session was cleared: `clearAuthenticated()` nulls the user but leaves mode
* "enabled" while the redirect to /login is in flight, and reading that as
* admin would flash install-wide controls at a signed-out caller.
*
* A missing mode is the slice default, which is "disabled" for backends old
* enough to send no auth block at all.
*/
export function isAdminIdentity(mode: AuthMode | undefined, role: string | undefined): boolean {
if (mode === undefined || mode === "disabled") return true;
return role === "admin";
}
@@ -61,7 +61,6 @@ const DB_STATS: DatabaseStats = {
const SNAPSHOT: SnapshotInfo = {
name: "manual-1.db",
path: "/data/backups/manual-1.db",
size_bytes: 1024,
mtime: "2026-05-17T00:00:00Z",
kind: "manual",
-1
View File
@@ -47,7 +47,6 @@ export type SnapshotKind = "auto" | "manual";
export interface SnapshotInfo {
name: string;
path: string;
size_bytes: number;
/** ISO timestamp. */
mtime: string;
+2
View File
@@ -13,6 +13,7 @@
"actionDone": "Done",
"actionFailed": "Failed",
"actionRunning": "Running...",
"backupsAdminOnly": "Only administrators can create, download, restore, or delete backups.",
"backupsColumnActions": "Actions",
"backupsColumnCreated": "Created",
"backupsColumnKind": "Kind",
@@ -263,6 +264,7 @@
"storageActionPending": "Wait for the current storage action to finish.",
"storageActionsDescription": "Analyze for a read-only snapshot, or run the enabled cleanup rules when you want to recover space immediately.",
"storageActionsTitle": "Reclaim disk space safely",
"storageAdminOnly": "Only administrators can change storage settings or run maintenance.",
"storageAdoptCache": "Adopt cache",
"storageAdoptDialogDescription": "Kandev will be allowed to rotate and quarantine the existing cache at {{path}}. This path must be absolute and on the same filesystem as Kandev trash.",
"storageAdoptDialogFallbackPath": "the selected path",
+2
View File
@@ -13,6 +13,7 @@
"actionDone": "Ďōńē",
"actionFailed": "Ƒàĩĺēď",
"actionRunning": "Ŕũńńĩńĝ...",
"backupsAdminOnly": "Ōńĺŷ àďḿĩńĩśţŕàţōŕś ćàń ćŕēàţē, ďōŵńĺōàď, ŕēśţōŕē, ōŕ ďēĺēţē ƀàćķũƥś.",
"backupsColumnActions": "Àćţĩōńś",
"backupsColumnCreated": "Ćŕēàţēď",
"backupsColumnKind": "Ķĩńď",
@@ -263,6 +264,7 @@
"storageActionPending": "Ŵàĩţ ƒōŕ ţĥē ćũŕŕēńţ śţōŕàĝē àćţĩōń ţō ƒĩńĩśĥ.",
"storageActionsDescription": "Àńàĺŷźē ƒōŕ à ŕēàď-ōńĺŷ śńàƥśĥōţ, ōŕ ŕũń ţĥē ēńàƀĺēď ćĺēàńũƥ ŕũĺēś ŵĥēń ŷōũ ŵàńţ ţō ŕēćōvēŕ śƥàćē ĩḿḿēďĩàţēĺŷ.",
"storageActionsTitle": "Ŕēćĺàĩḿ ďĩśķ śƥàćē śàƒēĺŷ",
"storageAdminOnly": "Ōńĺŷ àďḿĩńĩśţŕàţōŕś ćàń ćĥàńĝē śţōŕàĝē śēţţĩńĝś ōŕ ŕũń ḿàĩńţēńàńćē.",
"storageAdoptCache": "Àďōƥţ ćàćĥē",
"storageAdoptDialogDescription": "Ķàńďēv ŵĩĺĺ ƀē àĺĺōŵēď ţō ŕōţàţē àńď qũàŕàńţĩńē ţĥē ēxĩśţĩńĝ ćàćĥē àţ {{path}}. Ţĥĩś ƥàţĥ ḿũśţ ƀē àƀśōĺũţē àńď ōń ţĥē śàḿē ƒĩĺēśŷśţēḿ àś Ķàńďēv ţŕàśĥ.",
"storageAdoptDialogFallbackPath": "ţĥē śēĺēćţēď ƥàţĥ",
+2
View File
@@ -13,6 +13,7 @@
"actionDone": "Concluído",
"actionFailed": "Falhou",
"actionRunning": "Em execução...",
"backupsAdminOnly": "Só os administradores podem criar, transferir, restaurar ou eliminar cópias de segurança.",
"backupsColumnActions": "Ações",
"backupsColumnCreated": "Criada",
"backupsColumnKind": "Tipo",
@@ -258,6 +259,7 @@
"storageActionPending": "Aguarde que a ação de armazenamento atual termine.",
"storageActionsDescription": "Analise para obter um retrato só de leitura ou execute as regras de limpeza ativadas quando quiser recuperar espaço de imediato.",
"storageActionsTitle": "Recuperar espaço em disco com segurança",
"storageAdminOnly": "Só os administradores podem alterar as definições de armazenamento ou executar a manutenção.",
"storageAdoptCache": "Adotar a cache",
"storageAdoptDialogDescription": "O Kandev passa a poder rodar e colocar em quarentena a cache existente em {{path}}. Este caminho tem de ser absoluto e estar no mesmo sistema de ficheiros da reciclagem do Kandev.",
"storageAdoptDialogFallbackPath": "o caminho selecionado",
+2
View File
@@ -13,6 +13,7 @@
"actionDone": "完成",
"actionFailed": "失败",
"actionRunning": "运行中…",
"backupsAdminOnly": "只有管理员可以创建、下载、恢复或删除备份。",
"backupsColumnActions": "操作",
"backupsColumnCreated": "创建时间",
"backupsColumnKind": "类型",
@@ -260,6 +261,7 @@
"storageActionPending": "请等待当前存储操作完成。",
"storageActionsDescription": "分析可得到只读快照;若要立即回收空间,可运行已启用的清理规则。",
"storageActionsTitle": "安全回收磁盘空间",
"storageAdminOnly": "只有管理员可以更改存储设置或运行维护。",
"storageAdoptCache": "接管缓存",
"storageAdoptDialogDescription": "将允许 Kandev 轮换并隔离位于 {{path}} 的现有缓存。路径必须为绝对路径,且与 Kandev 回收站位于同一文件系统。",
"storageAdoptDialogFallbackPath": "所选路径",
+2
View File
@@ -13,6 +13,7 @@
"actionDone": "完成",
"actionFailed": "失敗",
"actionRunning": "執行中…",
"backupsAdminOnly": "只有管理員可以建立、下載、還原或刪除備份。",
"backupsColumnActions": "操作",
"backupsColumnCreated": "建立時間",
"backupsColumnKind": "類型",
@@ -260,6 +261,7 @@
"storageActionPending": "請等待目前存儲操作完成。",
"storageActionsDescription": "分析可得到只讀快照;若要立即回收空間,可執行已啓用的清理規則。",
"storageActionsTitle": "安全回收磁碟空間",
"storageAdminOnly": "只有管理員可以更改存儲設定或執行維護。",
"storageAdoptCache": "接管快取",
"storageAdoptDialogDescription": "將允許 Kandev 輪換並隔離位於 {{path}} 的現有快取。路徑必須為絕對路徑,且與 Kandev 回收站位於同一檔案系統。",
"storageAdoptDialogFallbackPath": "所選路徑",
+2
View File
@@ -13,6 +13,7 @@
"actionDone": "完成",
"actionFailed": "失敗",
"actionRunning": "執行中…",
"backupsAdminOnly": "只有管理員可以建立、下載、還原或刪除備份。",
"backupsColumnActions": "操作",
"backupsColumnCreated": "建立時間",
"backupsColumnKind": "型別",
@@ -260,6 +261,7 @@
"storageActionPending": "請等待目前儲存操作完成。",
"storageActionsDescription": "分析可得到只讀快照;若要立即回收空間,可執行已啟用的清理規則。",
"storageActionsTitle": "安全回收磁碟空間",
"storageAdminOnly": "只有管理員可以更改儲存設定或執行維護。",
"storageAdoptCache": "接管快取",
"storageAdoptDialogDescription": "將允許 Kandev 輪換並隔離位於 {{path}} 的現有快取。路徑必須為絕對路徑,且與 Kandev 回收站位於同一檔案系統。",
"storageAdoptDialogFallbackPath": "所選路徑",
+9
View File
@@ -51,6 +51,15 @@ A server that listens on non-loopback interfaces with authentication disabled lo
Roles: `admin` (user management, authentication settings, destructive system operations, feature toggles) and `member` (everything else, scoped to their own workspaces).
Install-wide data in `Settings > System > Data & storage` follows the same
split. Members can read the database stats, the backup listing, and the storage
usage, policy, run history, and quarantine contents. Creating, downloading,
restoring, and deleting a backup is admin only, because a backup is a copy of
the whole database and downloading one would export every user's workspaces.
Changing storage settings, adopting a Go cache, and running an analysis,
cleanup, or quarantine restore/purge are admin only for the same reason
database vacuum, optimize, and reset are: they act on the whole install.
## Personal access tokens
`Settings > Account > API Tokens`. Tokens look like `kandev_pat_…`, are shown **once** at creation, and are sent as a bearer header: