This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
const CurrentSchemaVersion = 2
|
||||
|
||||
var slugPattern = regexp.MustCompile("^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
|
||||
type Document map[string]any
|
||||
|
||||
func Validate(document Document) error {
|
||||
if document == nil {
|
||||
return errors.New("dashboard document is required")
|
||||
}
|
||||
if version, ok := number(document["schemaVersion"]); !ok || version < 1 || version > CurrentSchemaVersion {
|
||||
return errors.New("unsupported dashboard schema version")
|
||||
}
|
||||
for _, field := range []string{"id", "slug", "name", "scope", "variables", "widgets", "settings"} {
|
||||
if _, ok := document[field]; !ok {
|
||||
return fmt.Errorf("dashboard field %q is required", field)
|
||||
}
|
||||
}
|
||||
slug, ok := document["slug"].(string)
|
||||
if !ok || !slugPattern.MatchString(slug) || len(slug) > 80 {
|
||||
return errors.New("invalid dashboard slug")
|
||||
}
|
||||
name, ok := document["name"].(string)
|
||||
if !ok || name == "" || len(name) > 120 {
|
||||
return errors.New("invalid dashboard name")
|
||||
}
|
||||
scope, ok := document["scope"].(string)
|
||||
if !ok || scope != "personal" && scope != "shared" && scope != "system" {
|
||||
return errors.New("invalid dashboard scope")
|
||||
}
|
||||
if _, ok := document["widgets"].([]any); !ok {
|
||||
return errors.New("dashboard widgets must be an array")
|
||||
}
|
||||
if _, ok := document["variables"].([]any); !ok {
|
||||
return errors.New("dashboard variables must be an array")
|
||||
}
|
||||
if _, ok := document["settings"].(map[string]any); !ok {
|
||||
return errors.New("dashboard settings must be an object")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Migrate(document Document) (Document, error) {
|
||||
if err := Validate(document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
version, _ := number(document["schemaVersion"])
|
||||
if version == CurrentSchemaVersion {
|
||||
return clone(document), nil
|
||||
}
|
||||
migrated := clone(document)
|
||||
migrated["schemaVersion"] = CurrentSchemaVersion
|
||||
settings := migrated["settings"].(map[string]any)
|
||||
if _, ok := settings["live"]; !ok {
|
||||
settings["live"] = false
|
||||
}
|
||||
if _, ok := settings["refreshSeconds"]; !ok {
|
||||
settings["refreshSeconds"] = 30
|
||||
}
|
||||
return migrated, nil
|
||||
}
|
||||
func clone(document Document) Document {
|
||||
encoded, _ := json.Marshal(document)
|
||||
var result Document
|
||||
_ = json.Unmarshal(encoded, &result)
|
||||
return result
|
||||
}
|
||||
func number(value any) (int, bool) {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v, true
|
||||
case float64:
|
||||
return int(v), v == float64(int(v))
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package dashboard
|
||||
|
||||
import "testing"
|
||||
|
||||
func valid() Document {
|
||||
return Document{"schemaVersion": 1, "id": "00000000-0000-0000-0000-000000000001", "slug": "overview", "name": "Overview", "scope": "system", "variables": []any{}, "widgets": []any{}, "settings": map[string]any{}}
|
||||
}
|
||||
func TestInvalidDocumentRejected(t *testing.T) {
|
||||
doc := valid()
|
||||
delete(doc, "widgets")
|
||||
if err := Validate(doc); err == nil {
|
||||
t.Fatal("expected invalid document rejection")
|
||||
}
|
||||
}
|
||||
func TestMigrationIsDeterministicAndPreservesCustomSettings(t *testing.T) {
|
||||
doc := valid()
|
||||
doc["settings"].(map[string]any)["refreshSeconds"] = 90
|
||||
first, err := Migrate(doc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := Migrate(doc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first["schemaVersion"] != 2 || first["settings"].(map[string]any)["refreshSeconds"] != float64(90) {
|
||||
t.Fatalf("migration overwrote custom value: %+v", first)
|
||||
}
|
||||
if len(first["widgets"].([]any)) != len(second["widgets"].([]any)) {
|
||||
t.Fatal("migration not deterministic")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
func mapDatabaseError(err error) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return ErrConflict
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/database"
|
||||
)
|
||||
|
||||
func TestDashboardVersionIsImmutableInPostgreSQL(t *testing.T) {
|
||||
dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("PULSE_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
pool, err := database.NewPool(ctx, database.Config{URL: dsn})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := database.Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const dashboardID = "00000000-0000-0000-0000-0000000000d1"
|
||||
const versionID = "00000000-0000-0000-0000-0000000000f1"
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM dashboards WHERE id=$1`, dashboardID)
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO dashboards (id,slug,name,scope) VALUES ($1,'m3-test','M3 test','system')`, dashboardID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO dashboard_versions (id,dashboard_id,version_number,schema_version,document) VALUES ($1,$2,1,1,'{}')`, versionID, dashboardID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE dashboard_versions SET change_summary='mutated' WHERE id=$1`, versionID); err == nil {
|
||||
t.Fatal("expected immutable update failure")
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `DELETE FROM dashboards WHERE id=$1`, dashboardID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrConflict = errors.New("dashboard revision conflict")
|
||||
var ErrNotFound = errors.New("dashboard not found")
|
||||
var ErrForbidden = errors.New("dashboard access denied")
|
||||
|
||||
type Summary struct {
|
||||
ID, Slug, Name, Description, OwnerID, Scope string
|
||||
ArchivedAt *time.Time
|
||||
Revision int64
|
||||
CurrentVersion int
|
||||
}
|
||||
type Version struct {
|
||||
ID, DashboardID string
|
||||
Number, SchemaVersion int
|
||||
Document Document
|
||||
ChangeSummary, CreatedBy string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
type Repository struct{ Pool *pgxpool.Pool }
|
||||
|
||||
func (r Repository) CanAccess(ctx context.Context, id, actor string) (bool, error) {
|
||||
if r.Pool == nil {
|
||||
return false, errors.New("dashboard repository is not configured")
|
||||
}
|
||||
var allowed bool
|
||||
err := r.Pool.QueryRow(ctx, `SELECT EXISTS (
|
||||
SELECT 1 FROM dashboards
|
||||
WHERE id=$1 AND archived_at IS NULL
|
||||
AND (scope IN ('shared','system') OR owner_user_id=(SELECT id FROM users WHERE external_subject=$2))
|
||||
)`, id, actor).Scan(&allowed)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check dashboard access: %w", err)
|
||||
}
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
func (r Repository) Create(ctx context.Context, actor string, document Document, changeSummary string) (Summary, Version, error) {
|
||||
if err := Validate(document); err != nil {
|
||||
return Summary{}, Version{}, err
|
||||
}
|
||||
id, ok := document["id"].(string)
|
||||
if !ok || id == "" {
|
||||
return Summary{}, Version{}, errors.New("dashboard id is required")
|
||||
}
|
||||
slug, _ := document["slug"].(string)
|
||||
name, _ := document["name"].(string)
|
||||
scope, _ := document["scope"].(string)
|
||||
description, _ := document["description"].(string)
|
||||
tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return Summary{}, Version{}, fmt.Errorf("begin dashboard create: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
docJSON, _ := json.Marshal(document)
|
||||
versionID := idForVersion(id, 1)
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO dashboards (id,slug,name,description,owner_user_id,scope) VALUES ($1,$2,$3,$4,(SELECT id FROM users WHERE external_subject=$5),$6)`, id, slug, name, description, actor, scope); err != nil {
|
||||
err = mapDatabaseError(err)
|
||||
return Summary{}, Version{}, fmt.Errorf("create dashboard: %w", err)
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO dashboard_versions (id,dashboard_id,version_number,schema_version,document,change_summary,created_by) VALUES ($1,$2,1,$3,$4,$5,(SELECT id FROM users WHERE external_subject=$6))`, versionID, id, CurrentSchemaVersion, docJSON, changeSummary, actor); err != nil {
|
||||
return Summary{}, Version{}, fmt.Errorf("create dashboard version: %w", err)
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `UPDATE dashboards SET current_version_id=$1 WHERE id=$2`, versionID, id); err != nil {
|
||||
return Summary{}, Version{}, fmt.Errorf("set current dashboard version: %w", err)
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return Summary{}, Version{}, fmt.Errorf("commit dashboard create: %w", err)
|
||||
}
|
||||
createdSummary, createdVersion, err := r.Get(ctx, id, actor)
|
||||
if err != nil {
|
||||
return Summary{}, Version{}, err
|
||||
}
|
||||
return createdSummary, createdVersion, nil
|
||||
}
|
||||
|
||||
func (r Repository) UpdateDocument(ctx context.Context, id, actor string, expected int64, document Document, summary string) (Summary, error) {
|
||||
if err := Validate(document); err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var currentVersionID string
|
||||
var currentVersion int
|
||||
var currentJSON []byte
|
||||
var current Summary
|
||||
var canEdit bool
|
||||
err = tx.QueryRow(ctx, `SELECT id,slug,name,description,COALESCE(owner_user_id::text,''),scope,archived_at,revision,current_version_id,(owner_user_id=(SELECT id FROM users WHERE external_subject=$2) OR scope IN ('shared','system')) FROM dashboards WHERE id=$1 FOR UPDATE`, id, actor).Scan(¤t.ID, ¤t.Slug, ¤t.Name, ¤t.Description, ¤t.OwnerID, ¤t.Scope, ¤t.ArchivedAt, ¤t.Revision, ¤tVersionID, &canEdit)
|
||||
if err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
if !canEdit {
|
||||
return Summary{}, ErrForbidden
|
||||
}
|
||||
if current.Revision != expected {
|
||||
return Summary{}, ErrConflict
|
||||
}
|
||||
if err = tx.QueryRow(ctx, `SELECT version_number,document FROM dashboard_versions WHERE id=$1`, currentVersionID).Scan(¤tVersion, ¤tJSON); err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
newJSON, _ := json.Marshal(document)
|
||||
var stored, normalized Document
|
||||
_ = json.Unmarshal(currentJSON, &stored)
|
||||
_ = json.Unmarshal(newJSON, &normalized)
|
||||
if reflect.DeepEqual(stored, normalized) {
|
||||
current.CurrentVersion = currentVersion
|
||||
return current, nil
|
||||
}
|
||||
next := currentVersion + 1
|
||||
versionID := idForVersion(id, next)
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO dashboard_versions (id,dashboard_id,version_number,schema_version,document,change_summary,created_by) VALUES ($1,$2,$3,$4,$5,$6,(SELECT id FROM users WHERE external_subject=$7))`, versionID, id, next, CurrentSchemaVersion, newJSON, summary, actor); err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `UPDATE dashboards SET current_version_id=$1,revision=revision+1,updated_at=now() WHERE id=$2 AND revision=$3`, versionID, id, expected)
|
||||
if err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return Summary{}, ErrConflict
|
||||
}
|
||||
current.Revision++
|
||||
current.CurrentVersion = next
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func (r Repository) Restore(ctx context.Context, id, actor string, expected int64, versionNumber int) (Summary, error) {
|
||||
version, err := r.GetVersion(ctx, id, actor, versionNumber)
|
||||
if err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
return r.UpdateDocument(ctx, id, actor, expected, version.Document, fmt.Sprintf("restore version %d", versionNumber))
|
||||
}
|
||||
|
||||
func (r Repository) Get(ctx context.Context, id, actor string) (Summary, Version, error) {
|
||||
var s Summary
|
||||
var versionID string
|
||||
err := r.Pool.QueryRow(ctx, `SELECT id,slug,name,description,COALESCE(owner_user_id::text,''),scope,archived_at,revision,current_version_id FROM dashboards WHERE id=$1 AND (scope IN ('shared','system') OR owner_user_id=(SELECT id FROM users WHERE external_subject=$2))`, id, actor).Scan(&s.ID, &s.Slug, &s.Name, &s.Description, &s.OwnerID, &s.Scope, &s.ArchivedAt, &s.Revision, &versionID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Summary{}, Version{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Summary{}, Version{}, err
|
||||
}
|
||||
var raw []byte
|
||||
var v Version
|
||||
err = r.Pool.QueryRow(ctx, `SELECT id,dashboard_id,version_number,schema_version,document,change_summary,COALESCE(created_by::text,''),created_at FROM dashboard_versions WHERE id=$1`, versionID).Scan(&v.ID, &v.DashboardID, &v.Number, &v.SchemaVersion, &raw, &v.ChangeSummary, &v.CreatedBy, &v.CreatedAt)
|
||||
if err != nil {
|
||||
return Summary{}, Version{}, err
|
||||
}
|
||||
if err = json.Unmarshal(raw, &v.Document); err != nil {
|
||||
return Summary{}, Version{}, errors.New("invalid stored dashboard document")
|
||||
}
|
||||
s.CurrentVersion = v.Number
|
||||
return s, v, nil
|
||||
}
|
||||
|
||||
func (r Repository) List(ctx context.Context, actor string, limit int) ([]Summary, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, errors.New("dashboard page limit is invalid")
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `SELECT d.id,d.slug,d.name,d.description,COALESCE(d.owner_user_id::text,''),d.scope,d.archived_at,d.revision,v.version_number FROM dashboards d JOIN dashboard_versions v ON v.id=d.current_version_id WHERE d.scope <> 'personal' OR d.owner_user_id=(SELECT id FROM users WHERE external_subject=$1) ORDER BY d.name ASC,d.id ASC LIMIT $2`, actor, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list dashboards: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]Summary, 0, limit)
|
||||
for rows.Next() {
|
||||
var s Summary
|
||||
if err := rows.Scan(&s.ID, &s.Slug, &s.Name, &s.Description, &s.OwnerID, &s.Scope, &s.ArchivedAt, &s.Revision, &s.CurrentVersion); err != nil {
|
||||
return nil, fmt.Errorf("scan dashboard: %w", err)
|
||||
}
|
||||
result = append(result, s)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r Repository) UpdateMetadata(ctx context.Context, id, actor string, expected int64, name, description string) (Summary, error) {
|
||||
tag, err := r.Pool.Exec(ctx, `UPDATE dashboards SET name=$1,description=$2,revision=revision+1,updated_at=now() WHERE id=$3 AND revision=$4 AND (owner_user_id=(SELECT id FROM users WHERE external_subject=$5) OR scope IN ('shared','system'))`, name, description, id, expected, actor)
|
||||
if err != nil {
|
||||
return Summary{}, fmt.Errorf("update dashboard metadata: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return Summary{}, ErrConflict
|
||||
}
|
||||
s, _, err := r.Get(ctx, id, actor)
|
||||
return s, err
|
||||
}
|
||||
|
||||
func (r Repository) Archive(ctx context.Context, id, actor string, expected int64) (Summary, error) {
|
||||
tag, err := r.Pool.Exec(ctx, `UPDATE dashboards SET archived_at=now(),revision=revision+1,updated_at=now() WHERE id=$1 AND revision=$2 AND (owner_user_id=(SELECT id FROM users WHERE external_subject=$3) OR scope IN ('shared','system'))`, id, expected, actor)
|
||||
if err != nil {
|
||||
return Summary{}, fmt.Errorf("archive dashboard: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return Summary{}, ErrConflict
|
||||
}
|
||||
s, _, err := r.Get(ctx, id, actor)
|
||||
return s, err
|
||||
}
|
||||
|
||||
func (r Repository) Versions(ctx context.Context, id, actor string, limit int) ([]Version, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, errors.New("version page limit is invalid")
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `SELECT v.id,v.dashboard_id,v.version_number,v.schema_version,v.document,v.change_summary,COALESCE(v.created_by::text,''),v.created_at FROM dashboard_versions v JOIN dashboards d ON d.id=v.dashboard_id WHERE v.dashboard_id=$1 AND (d.scope IN ('shared','system') OR d.owner_user_id=(SELECT id FROM users WHERE external_subject=$2)) ORDER BY v.version_number DESC LIMIT $3`, id, actor, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list dashboard versions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]Version, 0, limit)
|
||||
for rows.Next() {
|
||||
var v Version
|
||||
var raw []byte
|
||||
if err := rows.Scan(&v.ID, &v.DashboardID, &v.Number, &v.SchemaVersion, &raw, &v.ChangeSummary, &v.CreatedBy, &v.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(raw, &v.Document); err != nil {
|
||||
return nil, errors.New("invalid stored dashboard document")
|
||||
}
|
||||
result = append(result, v)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
func (r Repository) Clone(ctx context.Context, actor, sourceID, slug, name string) (Summary, Version, error) {
|
||||
summary, version, err := r.Get(ctx, sourceID, actor)
|
||||
if err != nil {
|
||||
return Summary{}, Version{}, err
|
||||
}
|
||||
var allowed bool
|
||||
if err := r.Pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM dashboards WHERE id=$1 AND (scope <> 'personal' OR owner_user_id=(SELECT id FROM users WHERE external_subject=$2)))`, sourceID, actor).Scan(&allowed); err != nil {
|
||||
return Summary{}, Version{}, err
|
||||
}
|
||||
if !allowed {
|
||||
return Summary{}, Version{}, ErrForbidden
|
||||
}
|
||||
if slug == "" {
|
||||
slug = summary.Slug + "-copy"
|
||||
}
|
||||
if name == "" {
|
||||
name = summary.Name + " (kopie)"
|
||||
}
|
||||
copyDoc := clone(version.Document)
|
||||
copyDoc["id"] = newUUID()
|
||||
copyDoc["slug"] = slug
|
||||
copyDoc["name"] = name
|
||||
copyDoc["scope"] = "personal"
|
||||
return r.Create(ctx, actor, copyDoc, "cloned dashboard")
|
||||
}
|
||||
|
||||
func newUUID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "00000000-0000-4000-8000-000000000000"
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
func idForVersion(id string, number int) string {
|
||||
digest := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", id, number)))
|
||||
digest[6] = (digest[6] & 0x0f) | 0x50
|
||||
digest[8] = (digest[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", digest[0:4], digest[4:6], digest[6:8], digest[8:10], digest[10:16])
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/database"
|
||||
)
|
||||
|
||||
func TestDashboardRepositoryPostgreSQL(t *testing.T) {
|
||||
dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("PULSE_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 10, MinConns: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := database.Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
actor := "00000000-0000-0000-0000-0000000003a1"
|
||||
otherActor := "00000000-0000-0000-0000-0000000003a2"
|
||||
if _, err := pool.Exec(ctx, "INSERT INTO users (id, external_subject, display_name) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING", actor, actor, "M3 actor"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, "INSERT INTO users (id, external_subject, display_name) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING", otherActor, otherActor, "M3 other actor"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := Repository{Pool: pool}
|
||||
id := "00000000-0000-0000-0000-0000000003d1"
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM dashboards WHERE id=$1 OR slug IN ('m3-repo','m3-repo-copy')", id)
|
||||
doc := Document{"schemaVersion": 2, "id": id, "slug": "m3-repo", "name": "M3 Repo", "scope": "personal", "variables": []any{}, "widgets": []any{}, "settings": map[string]any{"refreshSeconds": 30}}
|
||||
|
||||
summary, version, err := repo.Create(ctx, actor, doc, "initial")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version.Number != 1 || summary.Revision != 1 {
|
||||
t.Fatalf("create=%+v/%+v", summary, version)
|
||||
}
|
||||
same, err := repo.UpdateDocument(ctx, id, actor, 1, doc, "noop")
|
||||
if err != nil || same.Revision != 1 {
|
||||
t.Fatalf("noop=%+v err=%v", same, err)
|
||||
}
|
||||
doc["name"] = "Changed"
|
||||
updated, err := repo.UpdateDocument(ctx, id, actor, 1, doc, "edit")
|
||||
if err != nil || updated.Revision != 2 || updated.CurrentVersion != 2 {
|
||||
t.Fatalf("update=%+v err=%v", updated, err)
|
||||
}
|
||||
if _, err := repo.UpdateDocument(ctx, id, actor, 1, doc, "stale"); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected conflict, got %v", err)
|
||||
}
|
||||
restored, err := repo.Restore(ctx, id, actor, 2, 1)
|
||||
if err != nil || restored.Revision != 3 || restored.CurrentVersion != 3 {
|
||||
t.Fatalf("restore=%+v err=%v", restored, err)
|
||||
}
|
||||
|
||||
versions, err := repo.Versions(ctx, id, actor, 10)
|
||||
if err != nil || len(versions) != 3 {
|
||||
t.Fatalf("versions=%d err=%v", len(versions), err)
|
||||
}
|
||||
historical, err := repo.GetVersion(ctx, id, actor, 1)
|
||||
if err != nil || historical.Number != 1 {
|
||||
t.Fatalf("historical=%+v err=%v", historical, err)
|
||||
}
|
||||
if _, _, err := repo.Get(ctx, id, otherActor); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("personal dashboard leaked through direct read: %v", err)
|
||||
}
|
||||
if otherVersions, err := repo.Versions(ctx, id, otherActor, 10); err != nil || len(otherVersions) != 0 {
|
||||
t.Fatalf("personal dashboard versions leaked: count=%d err=%v", len(otherVersions), err)
|
||||
}
|
||||
if _, err := repo.GetVersion(ctx, id, otherActor, 1); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("personal dashboard version leaked through direct read: %v", err)
|
||||
}
|
||||
if _, _, err := repo.Create(ctx, actor, doc, "duplicate"); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected duplicate conflict, got %v", err)
|
||||
}
|
||||
|
||||
cloned, clonedVersion, err := repo.Clone(ctx, actor, id, "m3-repo-copy", "M3 Repo Copy")
|
||||
if err != nil || cloned.ID == id || clonedVersion.Number != 1 || cloned.Scope != "personal" {
|
||||
t.Fatalf("clone=%+v/%+v err=%v", cloned, clonedVersion, err)
|
||||
}
|
||||
listed, err := repo.List(ctx, actor, 10)
|
||||
if err != nil || len(listed) < 2 {
|
||||
t.Fatalf("list=%d err=%v", len(listed), err)
|
||||
}
|
||||
edited, err := repo.UpdateMetadata(ctx, id, actor, 3, "M3 Repo Renamed", "updated")
|
||||
if err != nil || edited.Revision != 4 {
|
||||
t.Fatalf("metadata=%+v err=%v", edited, err)
|
||||
}
|
||||
archived, err := repo.Archive(ctx, id, actor, 4)
|
||||
if err != nil || archived.ArchivedAt == nil || archived.Revision != 5 {
|
||||
t.Fatalf("archive=%+v err=%v", archived, err)
|
||||
}
|
||||
|
||||
if _, err := pool.Exec(ctx, "UPDATE dashboard_versions SET change_summary='bad' WHERE dashboard_id=$1", id); err == nil {
|
||||
t.Fatal("expected immutable version failure")
|
||||
}
|
||||
if _, err := pool.Exec(ctx, "DELETE FROM dashboards WHERE id=$1", id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, "DELETE FROM dashboards WHERE id=$1", cloned.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/database"
|
||||
)
|
||||
|
||||
func TestDashboardListTargetScalePostgreSQL(t *testing.T) {
|
||||
dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("PULSE_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 10, MinConns: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := database.Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actor := "00000000-0000-0000-0000-0000000003a1"
|
||||
if _, err := pool.Exec(ctx, "INSERT INTO users (id, external_subject, display_name) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING", actor, actor, "M3 scale actor"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _, _ = pool.Exec(context.Background(), "DELETE FROM dashboards WHERE slug LIKE 'm3-scale-%'") }()
|
||||
repo := Repository{Pool: pool}
|
||||
for i := 0; i < 150; i++ {
|
||||
id := fmt.Sprintf("00000000-0000-0000-0000-%012x", 0x5000+i)
|
||||
doc := Document{"schemaVersion": 2, "id": id, "slug": fmt.Sprintf("m3-scale-%03d", i), "name": fmt.Sprintf("M3 Scale %03d", i), "scope": "personal", "variables": []any{}, "widgets": []any{}, "settings": map[string]any{"refreshSeconds": 30}}
|
||||
if _, _, err := repo.Create(ctx, actor, doc, "scale fixture"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
samples := make([]time.Duration, 20)
|
||||
for i := range samples {
|
||||
start := time.Now()
|
||||
items, err := repo.List(ctx, actor, 100)
|
||||
samples[i] = time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) < 100 {
|
||||
t.Fatalf("list returned %d items", len(items))
|
||||
}
|
||||
}
|
||||
sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] })
|
||||
p95 := samples[len(samples)*95/100-1]
|
||||
t.Logf("target-scale dashboards=150 list_limit=100 p95=%s max=%s", p95, samples[len(samples)-1])
|
||||
if p95 > 250*time.Millisecond {
|
||||
t.Fatalf("dashboard list p95=%s exceeds 250ms budget", p95)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (r Repository) GetVersion(ctx context.Context, id, actor string, number int) (Version, error) {
|
||||
var v Version
|
||||
var raw []byte
|
||||
err := r.Pool.QueryRow(ctx, `SELECT v.id,v.dashboard_id,v.version_number,v.schema_version,v.document,v.change_summary,COALESCE(v.created_by::text,''),v.created_at FROM dashboard_versions v JOIN dashboards d ON d.id=v.dashboard_id WHERE v.dashboard_id=$1 AND v.version_number=$2 AND (d.scope IN ('shared','system') OR d.owner_user_id=(SELECT id FROM users WHERE external_subject=$3))`, id, number, actor).Scan(&v.ID, &v.DashboardID, &v.Number, &v.SchemaVersion, &raw, &v.ChangeSummary, &v.CreatedBy, &v.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Version{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Version{}, err
|
||||
}
|
||||
if err := json.Unmarshal(raw, &v.Document); err != nil {
|
||||
return Version{}, errors.New("invalid stored dashboard document")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
Reference in New Issue
Block a user