feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
161
poller/internal/store/devices.go
Normal file
161
poller/internal/store/devices.go
Normal file
@@ -0,0 +1,161 @@
|
||||
// Package store provides database access for the poller service.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Device represents a device row fetched from the devices table.
|
||||
// The poller reads ALL devices across all tenants (no RLS applied to poller_user).
|
||||
type Device struct {
|
||||
ID string
|
||||
TenantID string
|
||||
IPAddress string
|
||||
APIPort int
|
||||
APISSLPort int
|
||||
EncryptedCredentials []byte // legacy AES-256-GCM BYTEA
|
||||
EncryptedCredentialsTransit *string // OpenBao Transit ciphertext (TEXT, nullable)
|
||||
RouterOSVersion *string
|
||||
MajorVersion *int
|
||||
TLSMode string // "insecure" or "portal_ca"
|
||||
CACertPEM *string // PEM-encoded CA cert (only populated when TLSMode = "portal_ca")
|
||||
}
|
||||
|
||||
// DeviceStore manages PostgreSQL connections for device data access.
|
||||
type DeviceStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewDeviceStore creates a pgx connection pool and returns a DeviceStore.
|
||||
//
|
||||
// The databaseURL should use the poller_user role which has SELECT-only access
|
||||
// to the devices table and is not subject to RLS policies.
|
||||
func NewDeviceStore(ctx context.Context, databaseURL string) (*DeviceStore, error) {
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating pgx pool: %w", err)
|
||||
}
|
||||
|
||||
// Verify connectivity immediately.
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("pinging database: %w", err)
|
||||
}
|
||||
|
||||
return &DeviceStore{pool: pool}, nil
|
||||
}
|
||||
|
||||
// FetchDevices returns all devices from the database.
|
||||
//
|
||||
// The query reads across all tenants intentionally — the poller_user role has
|
||||
// SELECT-only access without RLS so it can poll all devices.
|
||||
func (s *DeviceStore) FetchDevices(ctx context.Context) ([]Device, error) {
|
||||
const query = `
|
||||
SELECT
|
||||
d.id::text,
|
||||
d.tenant_id::text,
|
||||
d.ip_address,
|
||||
d.api_port,
|
||||
d.api_ssl_port,
|
||||
d.encrypted_credentials,
|
||||
d.encrypted_credentials_transit,
|
||||
d.routeros_version,
|
||||
d.routeros_major_version,
|
||||
d.tls_mode,
|
||||
ca.cert_pem
|
||||
FROM devices d
|
||||
LEFT JOIN certificate_authorities ca
|
||||
ON d.tenant_id = ca.tenant_id
|
||||
AND d.tls_mode = 'portal_ca'
|
||||
WHERE d.encrypted_credentials IS NOT NULL
|
||||
OR d.encrypted_credentials_transit IS NOT NULL
|
||||
`
|
||||
|
||||
rows, err := s.pool.Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying devices: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var devices []Device
|
||||
for rows.Next() {
|
||||
var d Device
|
||||
if err := rows.Scan(
|
||||
&d.ID,
|
||||
&d.TenantID,
|
||||
&d.IPAddress,
|
||||
&d.APIPort,
|
||||
&d.APISSLPort,
|
||||
&d.EncryptedCredentials,
|
||||
&d.EncryptedCredentialsTransit,
|
||||
&d.RouterOSVersion,
|
||||
&d.MajorVersion,
|
||||
&d.TLSMode,
|
||||
&d.CACertPEM,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scanning device row: %w", err)
|
||||
}
|
||||
devices = append(devices, d)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating device rows: %w", err)
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// GetDevice returns a single device by ID for interactive command execution.
|
||||
func (s *DeviceStore) GetDevice(ctx context.Context, deviceID string) (Device, error) {
|
||||
const query = `
|
||||
SELECT
|
||||
d.id::text,
|
||||
d.tenant_id::text,
|
||||
d.ip_address,
|
||||
d.api_port,
|
||||
d.api_ssl_port,
|
||||
d.encrypted_credentials,
|
||||
d.encrypted_credentials_transit,
|
||||
d.routeros_version,
|
||||
d.routeros_major_version,
|
||||
d.tls_mode,
|
||||
ca.cert_pem
|
||||
FROM devices d
|
||||
LEFT JOIN certificate_authorities ca
|
||||
ON d.tenant_id = ca.tenant_id
|
||||
AND d.tls_mode = 'portal_ca'
|
||||
WHERE d.id = $1
|
||||
`
|
||||
var d Device
|
||||
err := s.pool.QueryRow(ctx, query, deviceID).Scan(
|
||||
&d.ID,
|
||||
&d.TenantID,
|
||||
&d.IPAddress,
|
||||
&d.APIPort,
|
||||
&d.APISSLPort,
|
||||
&d.EncryptedCredentials,
|
||||
&d.EncryptedCredentialsTransit,
|
||||
&d.RouterOSVersion,
|
||||
&d.MajorVersion,
|
||||
&d.TLSMode,
|
||||
&d.CACertPEM,
|
||||
)
|
||||
if err != nil {
|
||||
return Device{}, fmt.Errorf("querying device %s: %w", deviceID, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Pool returns the underlying pgxpool.Pool for shared use by other subsystems
|
||||
// (e.g., credential cache key_access_log inserts).
|
||||
func (s *DeviceStore) Pool() *pgxpool.Pool {
|
||||
return s.pool
|
||||
}
|
||||
|
||||
// Close closes the pgx connection pool.
|
||||
func (s *DeviceStore) Close() {
|
||||
s.pool.Close()
|
||||
}
|
||||
150
poller/internal/store/devices_integration_test.go
Normal file
150
poller/internal/store/devices_integration_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mikrotik-portal/poller/internal/store"
|
||||
"github.com/mikrotik-portal/poller/internal/testutil"
|
||||
)
|
||||
|
||||
func TestDeviceStore_FetchDevices_Integration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
connStr, cleanup := testutil.SetupPostgres(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
tenantID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
dummyCreds := []byte("dummy-encrypted-credentials")
|
||||
v7 := "7.16"
|
||||
major7 := 7
|
||||
|
||||
// Insert 3 devices WITH encrypted_credentials (should be returned).
|
||||
id1 := testutil.InsertTestDevice(t, connStr, store.Device{
|
||||
TenantID: tenantID,
|
||||
IPAddress: "192.168.1.1",
|
||||
APIPort: 8728,
|
||||
APISSLPort: 8729,
|
||||
EncryptedCredentials: dummyCreds,
|
||||
RouterOSVersion: &v7,
|
||||
MajorVersion: &major7,
|
||||
})
|
||||
id2 := testutil.InsertTestDevice(t, connStr, store.Device{
|
||||
TenantID: tenantID,
|
||||
IPAddress: "192.168.1.2",
|
||||
APIPort: 8728,
|
||||
APISSLPort: 8729,
|
||||
EncryptedCredentials: dummyCreds,
|
||||
})
|
||||
id3 := testutil.InsertTestDevice(t, connStr, store.Device{
|
||||
TenantID: tenantID,
|
||||
IPAddress: "192.168.1.3",
|
||||
APIPort: 8728,
|
||||
APISSLPort: 8729,
|
||||
EncryptedCredentials: dummyCreds,
|
||||
})
|
||||
|
||||
// Insert 1 device WITHOUT encrypted_credentials (should be excluded).
|
||||
_ = testutil.InsertTestDevice(t, connStr, store.Device{
|
||||
TenantID: tenantID,
|
||||
IPAddress: "192.168.1.99",
|
||||
APIPort: 8728,
|
||||
// EncryptedCredentials is nil -> excluded by FetchDevices WHERE clause
|
||||
})
|
||||
|
||||
ds, err := store.NewDeviceStore(ctx, connStr)
|
||||
require.NoError(t, err)
|
||||
defer ds.Close()
|
||||
|
||||
devices, err := ds.FetchDevices(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, devices, 3, "should return only devices with encrypted_credentials")
|
||||
|
||||
// Collect returned IDs for verification.
|
||||
returnedIDs := make(map[string]bool)
|
||||
for _, d := range devices {
|
||||
returnedIDs[d.ID] = true
|
||||
}
|
||||
assert.True(t, returnedIDs[id1], "device 1 should be returned")
|
||||
assert.True(t, returnedIDs[id2], "device 2 should be returned")
|
||||
assert.True(t, returnedIDs[id3], "device 3 should be returned")
|
||||
|
||||
// Verify fields on the device with version info.
|
||||
for _, d := range devices {
|
||||
if d.ID == id1 {
|
||||
assert.Equal(t, tenantID, d.TenantID)
|
||||
assert.Equal(t, "192.168.1.1", d.IPAddress)
|
||||
assert.Equal(t, 8728, d.APIPort)
|
||||
assert.Equal(t, 8729, d.APISSLPort)
|
||||
assert.Equal(t, dummyCreds, d.EncryptedCredentials)
|
||||
require.NotNil(t, d.RouterOSVersion)
|
||||
assert.Equal(t, "7.16", *d.RouterOSVersion)
|
||||
require.NotNil(t, d.MajorVersion)
|
||||
assert.Equal(t, 7, *d.MajorVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceStore_GetDevice_Integration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
connStr, cleanup := testutil.SetupPostgres(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
tenantID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
dummyCreds := []byte("dummy-encrypted-credentials")
|
||||
|
||||
id := testutil.InsertTestDevice(t, connStr, store.Device{
|
||||
TenantID: tenantID,
|
||||
IPAddress: "10.0.0.1",
|
||||
APIPort: 8728,
|
||||
APISSLPort: 8729,
|
||||
EncryptedCredentials: dummyCreds,
|
||||
})
|
||||
|
||||
ds, err := store.NewDeviceStore(ctx, connStr)
|
||||
require.NoError(t, err)
|
||||
defer ds.Close()
|
||||
|
||||
// Happy path: existing device.
|
||||
d, err := ds.GetDevice(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, id, d.ID)
|
||||
assert.Equal(t, tenantID, d.TenantID)
|
||||
assert.Equal(t, "10.0.0.1", d.IPAddress)
|
||||
assert.Equal(t, dummyCreds, d.EncryptedCredentials)
|
||||
|
||||
// Sad path: nonexistent device.
|
||||
_, err = ds.GetDevice(ctx, "00000000-0000-0000-0000-000000000000")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDeviceStore_FetchDevices_Empty_Integration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
connStr, cleanup := testutil.SetupPostgres(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
ds, err := store.NewDeviceStore(ctx, connStr)
|
||||
require.NoError(t, err)
|
||||
defer ds.Close()
|
||||
|
||||
devices, err := ds.FetchDevices(ctx)
|
||||
require.NoError(t, err)
|
||||
// FetchDevices returns nil slice when no rows exist (append on nil);
|
||||
// this is acceptable Go behavior. The important thing is no error.
|
||||
assert.Empty(t, devices, "should return empty result for empty database")
|
||||
}
|
||||
Reference in New Issue
Block a user