diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62df736..61f02c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,7 @@ jobs: PGPORT: 5432 PGUSER: test_user PGPASSWORD: password + POSTGRES_DB: postgres steps: - name: install Go @@ -111,14 +112,32 @@ jobs: with: fetch-depth: 1 - - name: unit tests postgres + - name: non-Go linux dependencies + run: | + sudo apt-get update + sudo apt-get install -qq libudev-dev + + - name: set up postgres users run: | psql --host $PGHOST \ --username="postgres" \ --dbname="postgres" \ --command="CREATE USER $PGUSER PASSWORD '$PGPASSWORD'" \ --command="ALTER USER $PGUSER CREATEDB" \ - --command="CREATE USER ${PGUSER}_ro PASSWORD '$PGPASSWORD'" \ + --command="CREATE USER ${PGUSER}_ro PASSWORD '${PGPASSWORD}_ro'" \ --command="\du" + echo ${PGHOST}:${PGPORT}:*:${PGUSER}:${PGPASSWORD} >> ~/.pgpass + echo ${PGHOST}:${PGPORT}:*:${PGUSER}_ro:${PGPASSWORD}_ro >> ~/.pgpass + chmod 600 ~/.pgpass + - name: unit tests postgres + run: | go test ./cmd/worklog/pgstore + + - name: integration tests postgres + uses: nick-fields/retry@v3 + with: + timeout_minutes: 10 + max_attempts: 3 + command: | + go test -tags postgres -run TestScripts/worklog_load_postgres diff --git a/cmd/worklog/README.md b/cmd/worklog/README.md index 3da6f25..052c4fb 100644 --- a/cmd/worklog/README.md +++ b/cmd/worklog/README.md @@ -1,6 +1,6 @@ # `worklog` -`worklog` is a module that records screen activity, screen-saver lock state and AFK status. It takes messages from the `watcher` module and records them in an SQLite database and serves a small dashboard page that shows work activity. +`worklog` is a module that records screen activity, screen-saver lock state and AFK status. It takes messages from the `watcher` module and records them in an SQLite or PostgreSQL database and serves a small dashboard page that shows work activity. Example configuration fragment (requires a kernel configuration fragment): ``` @@ -187,3 +187,9 @@ The CEL environment enables the CEL [optional types library](https://pkg.go.dev/ ## CEL extensions The CEL environment provides the [`Lib`](https://pkg.go.dev/github.com/kortschak/dex/internal/celext#Lib) and [`StateLib`](https://pkg.go.dev/github.com/kortschak/dex/internal/celext#StateLib) extensions from the celext package. `StateLib` is only available in `module.*.options.rules.*.src`. + +## PostgreSQL store + +When using PostgreSQL as a store, the `~/.pgpass` file MAY be used for password look-up for the primary connection to the database and MUST be used for the read-only connection. + +The read-only connection is made on start-up. Before connection, the read-only user, which is `${PGUSER}_ro` where `${PGUSER}` is the user for the primary connection, is checked for its ability to read the tables used by the store and for the ability to do any non-SELECT operations. If the user cannot read the tables, a warning is emitted, but the connection is made. If non-SELECT operations are allowed for the user, or the user can read other tables, no connection is made. Since this check is only made at start-up, there is a TOCTOU concern here, but exploiting this would require having user ALTER and GRANT grants at which point you have already lost the game. diff --git a/cmd/worklog/main.go b/cmd/worklog/main.go index b53a647..3690c54 100644 --- a/cmd/worklog/main.go +++ b/cmd/worklog/main.go @@ -187,6 +187,7 @@ type daemon struct { rMu sync.Mutex lastEvents map[string]*worklog.Event + dMu sync.Mutex // tMu is only used for protecting configuration of db. db atomicIfaceValue[storage] lastReport map[rpc.UID]worklog.Report @@ -359,7 +360,7 @@ func (d *daemon) Handle(ctx context.Context, req *jsonrpc2.Request) (any, error) } } - databaseDir, err := dbDir(m.Body) + scheme, databaseDir, err := dbDir(m.Body) if err != nil { d.log.LogAttrs(ctx, slog.LevelError, "configure database", slog.Any("error", err)) return nil, rpc.NewError(rpc.ErrCodeInvalidMessage, @@ -371,50 +372,86 @@ func (d *daemon) Handle(ctx context.Context, req *jsonrpc2.Request) (any, error) }, ) } - if databaseDir != "" { - dir, err := xdg.State(databaseDir) - switch err { - case nil: - case syscall.ENOENT: - var ok bool - dir, ok = xdg.StateHome() - if !ok { - d.log.LogAttrs(ctx, slog.LevelError, "configure database", slog.String("error", "no XDG_STATE_HOME")) - return nil, err + d.dMu.Lock() + defer d.dMu.Unlock() + switch { + default: + d.log.LogAttrs(ctx, slog.LevelError, "configure database", slog.String("error", "unknown scheme"), slog.String("url", m.Body.Options.Database)) + return nil, rpc.NewError(rpc.ErrCodeInvalidMessage, + err.Error(), + map[string]any{ + "type": rpc.ErrCodeParameters, + "database": m.Body.Options.Database, + }, + ) + case scheme == "": + // Do nothing. + case scheme == "postgres", scheme == "sqlite": + var ( + open opener + addr string + ) + switch { + case scheme == "postgres": + addr = m.Body.Options.Database + open = func(ctx context.Context, addr, host string) (storage, error) { + db, err := pgstore.Open(ctx, addr, host) + if _, ok := err.(pgstore.Warning); ok { + d.log.LogAttrs(ctx, slog.LevelWarn, "configure database", slog.Any("error", err)) + err = nil + } + return db, err } - dir = filepath.Join(dir, databaseDir) - err = os.Mkdir(dir, 0o750) - if err != nil { - err := err.(*os.PathError) // See godoc for os.Mkdir for why this is safe. - d.log.LogAttrs(ctx, slog.LevelError, "create database dir", slog.Any("error", err)) - return nil, rpc.NewError(rpc.ErrCodeInternal, + + case scheme == "sqlite": + dir, err := xdg.State(databaseDir) + switch err { + case nil: + case syscall.ENOENT: + var ok bool + dir, ok = xdg.StateHome() + if !ok { + d.log.LogAttrs(ctx, slog.LevelError, "configure database", slog.String("error", "no XDG_STATE_HOME")) + return nil, err + } + dir = filepath.Join(dir, databaseDir) + err = os.Mkdir(dir, 0o750) + if err != nil { + err := err.(*os.PathError) // See godoc for os.Mkdir for why this is safe. + d.log.LogAttrs(ctx, slog.LevelError, "create database dir", slog.Any("error", err)) + return nil, rpc.NewError(rpc.ErrCodeInternal, + err.Error(), + map[string]any{ + "type": rpc.ErrCodePath, + "op": err.Op, + "path": err.Path, + "err": fmt.Sprint(err.Err), + }, + ) + } + default: + d.log.LogAttrs(ctx, slog.LevelError, "configure database", slog.Any("error", err)) + return nil, jsonrpc2.NewError( + rpc.ErrCodeInternal, err.Error(), - map[string]any{ - "type": rpc.ErrCodePath, - "op": err.Op, - "path": err.Path, - "err": fmt.Sprint(err.Err), - }, ) } - default: - d.log.LogAttrs(ctx, slog.LevelError, "configure database", slog.Any("error", err)) - return nil, jsonrpc2.NewError( - rpc.ErrCodeInternal, - err.Error(), - ) + + addr = filepath.Join(dir, "db.sqlite") + open = func(ctx context.Context, addr, host string) (storage, error) { + return store.Open(ctx, addr, host) + } } - path := filepath.Join(dir, "db.sqlite") - if db := d.db.Load(); db == nil || path != db.Name() { - err = d.openDB(ctx, db, path, m.Body.Options.Hostname) + if db := d.db.Load(); !sameDB(db, addr) { + err = d.openDB(ctx, db, open, addr, m.Body.Options.Hostname) if err != nil { return nil, rpc.NewError(rpc.ErrCodeInternal, err.Error(), map[string]any{ "type": rpc.ErrCodeStoreErr, "op": "open", - "path": path, + "name": addr, }, ) } @@ -444,34 +481,41 @@ func (d *daemon) Handle(ctx context.Context, req *jsonrpc2.Request) (any, error) } } -func dbDir(cfg worklog.Config) (string, error) { +func dbDir(cfg worklog.Config) (scheme, dir string, err error) { opt := cfg.Options if opt.Database == "" { - return opt.DatabaseDir, nil + if opt.DatabaseDir != "" { + scheme = "sqlite" + } + return scheme, opt.DatabaseDir, nil } u, err := url.Parse(opt.Database) if err != nil { - return "", err + return "", "", err } switch u.Scheme { case "": - return "", errors.New("missing scheme in database configuration") + return "", "", errors.New("missing scheme in database configuration") case "sqlite": if opt.DatabaseDir != "" && u.Opaque != opt.DatabaseDir { - return "", fmt.Errorf("inconsistent database directory configuration: (%s:)%s != %s", u.Scheme, u.Opaque, opt.DatabaseDir) + return "", "", fmt.Errorf("inconsistent database directory configuration: (%s:)%s != %s", u.Scheme, u.Opaque, opt.DatabaseDir) } if u.Opaque == "" { - return "", fmt.Errorf("sqlite configuration missing opaque data: %s", opt.Database) + return "", "", fmt.Errorf("sqlite configuration missing opaque data: %s", opt.Database) } - return u.Opaque, nil + return u.Scheme, u.Opaque, nil default: if opt.DatabaseDir != "" { - return "", fmt.Errorf("inconsistent database configuration: both %s database and sqlite directory configured", u.Scheme) + return "", "", fmt.Errorf("inconsistent database configuration: both %s database and sqlite directory configured", u.Scheme) } - return "", nil + return u.Scheme, "", nil } } +func sameDB(db storage, name string) bool { + return db != nil && name == db.Name() +} + func (d *daemon) replaceTimezone(ctx context.Context, dynamic *bool) { if dynamic == nil { return @@ -579,26 +623,28 @@ func (d *daemon) configureRules(ctx context.Context, rules map[string]worklog.Ru d.rules.Store(ruleDetails) } -func (d *daemon) openDB(ctx context.Context, db storage, path, hostname string) error { +type opener = func(ctx context.Context, addr, hostname string) (storage, error) + +func (d *daemon) openDB(ctx context.Context, db storage, open opener, addr, hostname string) error { if db != nil { - d.log.LogAttrs(ctx, slog.LevelInfo, "close database", slog.String("path", db.Name())) + d.log.LogAttrs(ctx, slog.LevelInfo, "close database", slog.String("name", db.Name())) d.db.Store((storage)(nil)) db.Close(ctx) } - // store.Open may need to get the hostname, which may + // An opener may need to get the hostname, which may // wait indefinitely due to network unavailability. // So make a timeout and allow the fallback to the // kernel-provided hostname. This fallback is // implemented by store.Open. ctx, cancel := context.WithTimeout(ctx, time.Minute) defer cancel() - db, err := store.Open(ctx, path, hostname) + db, err := open(ctx, addr, hostname) if err != nil { d.log.LogAttrs(ctx, slog.LevelError, "open database", slog.Any("error", err)) return err } d.db.Store(db) - d.log.LogAttrs(ctx, slog.LevelInfo, "open database", slog.String("path", path)) + d.log.LogAttrs(ctx, slog.LevelInfo, "open database", slog.String("name", addr)) return nil } diff --git a/cmd/worklog/main_test.go b/cmd/worklog/main_test.go index b074f06..a9eb2ac 100644 --- a/cmd/worklog/main_test.go +++ b/cmd/worklog/main_test.go @@ -29,6 +29,7 @@ import ( "golang.org/x/sys/execabs" worklog "github.com/kortschak/dex/cmd/worklog/api" + "github.com/kortschak/dex/cmd/worklog/store" "github.com/kortschak/dex/internal/slogext" "github.com/kortschak/dex/rpc" ) @@ -267,7 +268,7 @@ func mergeAfk() int { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() d := newDaemon("worklog", log, &level, addSource, ctx, cancel) - err = d.openDB(ctx, nil, "db.sqlite3", "localhost") + err = d.openDB(ctx, nil, open, "db.sqlite3", "localhost") if err != nil { fmt.Fprintf(os.Stderr, "failed to create db: %v\n", err) return 1 @@ -536,7 +537,7 @@ func newTestDaemon(ctx context.Context, cancel context.CancelFunc, verbose bool, })) d := newDaemon("worklog", log, &level, addSource, ctx, cancel) - err := d.openDB(ctx, nil, dbName, "localhost") + err := d.openDB(ctx, nil, open, dbName, "localhost") if err != nil { fmt.Fprintf(os.Stderr, "failed to create db: %v\n", err) return nil, nil, nil, 1 @@ -568,6 +569,10 @@ func newTestDaemon(ctx context.Context, cancel context.CancelFunc, verbose bool, return d, db, webRules, 0 } +func open(ctx context.Context, addr, host string) (storage, error) { + return store.Open(ctx, addr, host) +} + func generateData(ts *testscript.TestScript, neg bool, args []string) { if neg { ts.Fatalf("unsupported: ! gen_data") @@ -1717,57 +1722,66 @@ func TestMergeReplacements(t *testing.T) { } var dbDirTests = []struct { - name string - config worklog.Config - want string - wantErr error + name string + config worklog.Config + wantScheme string + wantDir string + wantErr error }{ { name: "none", }, { - name: "deprecated", - config: mkDBDirOptions("database_directory", ""), - want: "database_directory", + name: "deprecated", + config: mkDBDirOptions("database_directory", ""), + wantScheme: "sqlite", + wantDir: "database_directory", }, { - name: "url_only_sqlite", - config: mkDBDirOptions("", "sqlite:database_directory"), - want: "database_directory", + name: "url_only_sqlite", + config: mkDBDirOptions("", "sqlite:database_directory"), + wantScheme: "sqlite", + wantDir: "database_directory", }, { - name: "url_only_postgres", - config: mkDBDirOptions("", "postgres://username:password@localhost:5432/database_name"), - want: "", + name: "url_only_postgres", + config: mkDBDirOptions("", "postgres://username:password@localhost:5432/database_name"), + wantScheme: "postgres", + wantDir: "", }, { - name: "both_consistent", - config: mkDBDirOptions("database_directory", "sqlite:database_directory"), - want: "database_directory", + name: "both_consistent", + config: mkDBDirOptions("database_directory", "sqlite:database_directory"), + wantScheme: "sqlite", + wantDir: "database_directory", }, { - name: "missing_scheme", - config: mkDBDirOptions("database_dir", "database_directory"), - want: "", - wantErr: errors.New("missing scheme in database configuration"), + name: "missing_scheme", + config: mkDBDirOptions("database_dir", "database_directory"), + wantScheme: "", + wantDir: "", + wantErr: errors.New("missing scheme in database configuration"), }, { - name: "both_inconsistent_sqlite", - config: mkDBDirOptions("database_dir", "sqlite:database_directory"), - want: "", - wantErr: errors.New("inconsistent database directory configuration: (sqlite:)database_directory != database_dir"), + name: "both_inconsistent_sqlite", + config: mkDBDirOptions("database_dir", "sqlite:database_directory"), + wantScheme: "", + wantDir: "", + wantErr: errors.New("inconsistent database directory configuration: (sqlite:)database_directory != database_dir"), }, { - name: "invalid_sqlite_url", - config: mkDBDirOptions("", "sqlite:/database_directory"), - want: "", - wantErr: errors.New("sqlite configuration missing opaque data: sqlite:/database_directory"), + name: "invalid_sqlite_url", + config: mkDBDirOptions("", "sqlite:/database_directory"), + wantScheme: "", + wantDir: "", + wantErr: errors.New("sqlite configuration missing opaque data: sqlite:/database_directory"), }, { - name: "both_inconsistent_postgres", - config: mkDBDirOptions("database_dir", "postgres://username:password@localhost:5432/database_name"), - want: "", - wantErr: errors.New("inconsistent database configuration: both postgres database and sqlite directory configured"), + name: "both_inconsistent_postgres", + config: mkDBDirOptions("database_dir", "postgres://username:password@localhost:5432/database_name"), + wantScheme: "", + wantDir: "", + wantErr: errors.New("inconsistent database configuration: both postgres database and sqlite directory configured"), }, } @@ -1781,12 +1795,15 @@ func mkDBDirOptions(dir, url string) worklog.Config { func TestDBDir(t *testing.T) { for _, test := range dbDirTests { t.Run(test.name, func(t *testing.T) { - got, err := dbDir(test.config) + gotScheme, gotDir, err := dbDir(test.config) if !sameError(err, test.wantErr) { t.Errorf("unexpected error calling dbDir: got:%v want:%v", err, test.wantErr) } - if got != test.want { - t.Errorf("unexpected result: got:%q want:%q", got, test.want) + if gotScheme != test.wantScheme { + t.Errorf("unexpected scheme result: got:%q want:%q", gotScheme, test.wantScheme) + } + if gotDir != test.wantDir { + t.Errorf("unexpected dir result: got:%q want:%q", gotDir, test.wantDir) } }) } diff --git a/main_postgres_test.go b/main_postgres_test.go new file mode 100644 index 0000000..7509294 --- /dev/null +++ b/main_postgres_test.go @@ -0,0 +1,9 @@ +// Copyright ©2024 Dan Kortschak. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build postgres + +package main + +func init() { postgres = true } diff --git a/main_test.go b/main_test.go index 579d75d..5abf17f 100644 --- a/main_test.go +++ b/main_test.go @@ -6,18 +6,22 @@ package main import ( "bytes" + "context" "crypto/tls" "encoding/json" "flag" "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "regexp" + "strings" "testing" "time" + "github.com/jackc/pgx/v5" "github.com/rogpeppe/go-internal/gotooltest" "github.com/rogpeppe/go-internal/testscript" ) @@ -25,6 +29,9 @@ import ( var ( update = flag.Bool("update", false, "update tests") keep = flag.Bool("keep", false, "keep $WORK directory after tests") + + // postgres indicates tests were invoked with -tags postgres. + postgres bool ) func TestMain(m *testing.M) { @@ -45,6 +52,9 @@ func TestScripts(t *testing.T) { Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ "sleep": sleep, "grep_from_file": grep, + "expand": expand, + "createdb": createDB, + "grant_read": grantReadAccess, }, Setup: func(e *testscript.Env) error { pwd, err := os.Getwd() @@ -52,8 +62,25 @@ func TestScripts(t *testing.T) { return err } e.Setenv("PKG_ROOT", pwd) + for _, k := range []string{ + "PGUSER", "PGPASSWORD", + "PGHOST", "PGPORT", + "POSTGRES_DB", + } { + if v, ok := os.LookupEnv(k); ok { + e.Setenv(k, v) + } + } return nil }, + Condition: func(cond string) (bool, error) { + switch cond { + case "postgres": + return postgres, nil + default: + return false, fmt.Errorf("unknown condition: %s", cond) + } + }, } if err := gotooltest.Setup(&p); err != nil { t.Fatal(err) @@ -97,6 +124,107 @@ func grep(ts *testscript.TestScript, neg bool, args []string) { } } +func expand(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported: ! expand") + } + if len(args) != 2 { + ts.Fatalf("usage: expand src dst") + } + src, err := os.ReadFile(ts.MkAbs(args[0])) + ts.Check(err) + src = []byte(os.Expand(string(src), func(key string) string { + return ts.Getenv(key) + })) + err = os.WriteFile(ts.MkAbs(args[1]), src, 0o644) + ts.Check(err) +} + +func createDB(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported: ! createdb") + } + if len(args) != 2 { + ts.Fatalf("usage: createdb postgres://user:password@host:port/server new_db") + } + u, err := url.Parse(args[0]) + ts.Check(err) + createTestDB(ts, u, args[1]) +} + +func createTestDB(ts *testscript.TestScript, u *url.URL, dbname string) { + ctx := context.Background() + db, err := pgx.Connect(ctx, u.String()) + if err != nil { + ts.Fatalf("failed to open admin database: %v", err) + } + _, err = db.Exec(ctx, "create database "+dbname) + if err != nil { + ts.Fatalf("failed to create test database: %v", err) + } + err = db.Close(ctx) + if err != nil { + ts.Fatalf("failed to close admin connection: %v", err) + } + + ts.Defer(func() { + dropTestDB(ts, ctx, u, dbname) + }) +} + +func dropTestDB(ts *testscript.TestScript, ctx context.Context, u *url.URL, dbname string) { + db, err := pgx.Connect(ctx, u.String()) + if err != nil { + ts.Logf("failed to open admin database: %v", err) + return + } + _, err = db.Exec(ctx, "drop database if exists "+dbname) + if err != nil { + ts.Logf("failed to drop test database: %v", err) + return + } + err = db.Close(ctx) + if err != nil { + ts.Logf("failed to close admin connection: %v", err) + } +} + +func grantReadAccess(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported: ! grant_read") + } + if len(args) != 2 { + ts.Fatalf("usage: grant_read postgres://user:password@host:port/dbname target_user") + } + + u, err := url.Parse(args[0]) + ts.Check(err) + ctx := context.Background() + db, err := pgx.Connect(ctx, u.String()) + if err != nil { + ts.Fatalf("failed to open database: %v", err) + } + + target := args[1] + statements := []string{ + fmt.Sprintf("GRANT CONNECT ON DATABASE %s TO %s", strings.TrimLeft(u.Path, "/"), target), + fmt.Sprintf("GRANT USAGE ON SCHEMA public TO %s", target), + fmt.Sprintf("GRANT SELECT ON ALL TABLES IN SCHEMA public TO %s", target), + } + for _, s := range statements { + _, err = db.Exec(ctx, s) + if err != nil { + ts.Logf("failed to execute grant: %q %v", s, err) + break + } + } + + err = db.Close(ctx) + if err != nil { + ts.Fatalf("failed to close connection: %v", err) + } +} + func get() int { jsonData := flag.Bool("json", false, "data from GET is JSON") flag.Parse() diff --git a/testdata/worklog_load_postgres.txt b/testdata/worklog_load_postgres.txt new file mode 100644 index 0000000..02b9204 --- /dev/null +++ b/testdata/worklog_load_postgres.txt @@ -0,0 +1,172 @@ +# Only run in a postgres-available environment. +[!postgres] stop 'Skipping postgres test.' + +# The build of worklog takes a fair while due to the size of the dependency +# tree, the size of some of the individual dependencies and the absence of +# caching when building within a test script. +[short] stop 'Skipping long test.' + +env HOME=${WORK} + +[linux] env XDG_CONFIG_HOME=${HOME}/.config +[linux] env XDG_RUNTIME_DIR=${HOME}/runtime +[linux] mkdir ${XDG_CONFIG_HOME}/dex +[linux] mkdir ${XDG_RUNTIME_DIR} +[linux] expand config.toml ${HOME}/.config/dex/config.toml + +[darwin] mkdir ${HOME}'/Library/Application Support/dex' +[darwin] expand config.toml ${HOME}'/Library/Application Support/dex/config.toml' + +env GOBIN=${WORK}/bin +env PATH=${GOBIN}:${PATH} +cd ${PKG_ROOT} +go install ./cmd/worklog +cd ${WORK} + +# Create the database... +createdb postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:${PGPORT}/${POSTGRES_DB} test_database +# and set up user details. +expand pgpass ${HOME}/.pgpass +chmod 600 ${HOME}/.pgpass + +# Start dex to load the data. +dex -log debug -lines &dex& +sleep 1s + +# Load the data... +POST dump.json http://localhost:9797/load/?replace=true +# and confirm. +GET -json http://localhost:9797/dump/ +cmp stdout want.json + +# Show that the non-granted user cannot read. +GET http://localhost:9797/query?sql=select+count(*)+as+event_count+from+events +cp stdout got_event_count_no_grant.json +grep_from_file want_event_count_no_grant.pattern got_event_count_no_grant.json + +# Grant the ro user read access... +grant_read postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:${PGPORT}/test_database ${PGUSER}_ro + +# and confirm that they can read. +GET http://localhost:9797/query?sql=select+count(*)+as+event_count+from+events +cmp stdout want_event_count_grant.json + +# Terminate dex to allow the test database to be dropped. +kill -INT dex +wait dex + +-- config.toml -- +[kernel] +device = [] +network = "tcp" + +[module.worklog] +path = "worklog" +log_mode = "log" +log_level = "debug" +log_add_source = true + +[module.worklog.options] +database = "postgres://${PGUSER}@${PGHOST}:${PGPORT}/test_database" +hostname = "localhost" +[module.worklog.options.web] +addr = "localhost:9797" +can_modify = true + +-- pgpass -- +${PGHOST}:${PGPORT}:*:${PGUSER}:${PGPASSWORD} +${PGHOST}:${PGPORT}:*:${PGUSER}_ro:${PGPASSWORD}_ro +-- dump.json -- +{ + "buckets": [ + { + "id": "afk_localhost", + "name": "afk-watcher", + "type": "afkstatus", + "client": "worklog", + "hostname": "localhost", + "created": "2023-12-04T17:21:27.424207553+10:30", + "events": [ + { + "bucket": "afk", + "id": 2, + "start": "2023-12-04T17:21:28.270750821+10:30", + "end": "2023-12-04T17:21:28.270750821+10:30", + "data": { + "afk": false, + "locked": false + } + } + ] + }, + { + "id": "window_localhost", + "name": "window-watcher", + "type": "currentwindow", + "client": "worklog", + "hostname": "localhost", + "created": "2023-12-04T17:21:27.428793055+10:30", + "events": [ + { + "bucket": "window", + "id": 1, + "start": "2023-12-04T17:21:28.270750821+10:30", + "end": "2023-12-04T17:21:28.270750821+10:30", + "data": { + "app": "Gnome-terminal", + "title": "Terminal" + } + } + ] + } + ] +} +-- want.json -- +{ + "buckets": [ + { + "id": "afk_localhost", + "name": "afk-watcher", + "type": "afkstatus", + "client": "worklog", + "hostname": "localhost", + "created": "2023-12-04T17:21:27.424207+10:30", + "events": [ + { + "bucket": "afk", + "id": 1, + "start": "2023-12-04T17:21:28.27075+10:30", + "end": "2023-12-04T17:21:28.27075+10:30", + "data": { + "afk": false, + "locked": false + } + } + ] + }, + { + "id": "window_localhost", + "name": "window-watcher", + "type": "currentwindow", + "client": "worklog", + "hostname": "localhost", + "created": "2023-12-04T17:21:27.428793+10:30", + "events": [ + { + "bucket": "window", + "id": 2, + "start": "2023-12-04T17:21:28.27075+10:30", + "end": "2023-12-04T17:21:28.27075+10:30", + "data": { + "app": "Gnome-terminal", + "title": "Terminal" + } + } + ] + } + ] +} +-- want_event_count_no_grant.pattern -- +{"err":"ERROR: permission denied for (?:relation|table) events \(SQLSTATE 42501\): ro user failed read capability checks"} +-- want_event_count_grant.json -- +[{"event_count":2}]