fix(auth): require admin for system backups and storage maintenance (#3036)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
+4
-1
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -47,7 +47,6 @@ export type SnapshotKind = "auto" | "manual";
|
||||
|
||||
export interface SnapshotInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
size_bytes: number;
|
||||
/** ISO timestamp. */
|
||||
mtime: string;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ţĥē śēĺēćţēď ƥàţĥ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "所选路径",
|
||||
|
||||
@@ -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": "所選路徑",
|
||||
|
||||
@@ -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": "所選路徑",
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user