Files
VoCat/internal/store/domain_test.go
T
MengMengCode 0d738d4ce4 feat: add custom phone number support to card policies
- Introduced a new field `custom_phone_number` in the CardPolicy model and database schema.
- Updated the API to handle custom phone number input, including validation and normalization.
- Modified the CardPolicyPanel component to allow users to set and save a custom phone number.
- Enhanced the settings API to include the custom phone number in responses and updates.
- Added tests to ensure the correct functionality of custom phone number handling.
- Removed hardcoded environment variable for VOCAT_ADDR in service files.
2026-08-11 21:58:28 +08:00

850 lines
28 KiB
Go

package store
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
)
func TestMigrationFromAuthenticationSchema(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "migration.db")
raw, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
for _, statement := range migrationStatements(1) {
if _, err := raw.ExecContext(ctx, statement); err != nil {
t.Fatalf("create v1 schema: %v", err)
}
}
if _, err := raw.ExecContext(ctx, `
INSERT INTO admins (id, username, password_hash, created_at, updated_at)
VALUES (1, 'legacy-admin', X'0102', 100, 100)
`); err != nil {
t.Fatal(err)
}
if _, err := raw.ExecContext(ctx, `PRAGMA user_version = 1`); err != nil {
t.Fatal(err)
}
if err := raw.Close(); err != nil {
t.Fatal(err)
}
database := openTestStore(t, path)
admin, err := database.CurrentAdmin(ctx)
if err != nil {
t.Fatalf("legacy admin missing after migration: %v", err)
}
if admin.Username != "legacy-admin" || !bytes.Equal(admin.PasswordHash, []byte{1, 2}) {
t.Fatalf("legacy admin changed during migration: %+v", admin)
}
var version int
if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
t.Fatal(err)
}
if version != schemaVersion {
t.Fatalf("schema version = %d, want %d", version, schemaVersion)
}
for _, table := range []string{
"devices", "device_runtime", "vowifi_runtime", "sms_messages",
"local_proxy_config", "upstream_proxies", "country_rules",
"device_proxy_bindings",
"notification_settings", "app_settings", "audit_events",
"log_events", "card_policies", "card_apn_profiles", "traffic_buckets",
"sms_send_attempts",
} {
var found string
err := database.db.QueryRowContext(ctx, `
SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?
`, table).Scan(&found)
if err != nil || found != table {
t.Fatalf("migrated table %q missing: %v", table, err)
}
}
}
func TestMigration7BackfillsSMSModemIMEI(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "sms-imei.db")
raw, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
for version := 1; version <= 6; version++ {
for _, statement := range migrationStatements(version) {
if _, err := raw.ExecContext(ctx, statement); err != nil {
t.Fatalf("create v%d schema: %v", version, err)
}
}
}
if _, err := raw.ExecContext(ctx, `
INSERT INTO devices (id, name, modem_imei, created_at, updated_at)
VALUES ('ec20_1', 'EC20', '867394042309830', 100, 100);
INSERT INTO sms_messages (
message_id, device_id, peer, direction, message_time, created_at, updated_at
) VALUES ('legacy-message', 'ec20_1', 'VOXI', 'inbound', 100, 100, 100);
PRAGMA user_version = 6;
`); err != nil {
t.Fatal(err)
}
if err := raw.Close(); err != nil {
t.Fatal(err)
}
database := openTestStore(t, path)
messages, err := database.ListSMSMessages(ctx, SMSFilter{ModemIMEI: "867394042309830"})
if err != nil || len(messages) != 1 || messages[0].DeviceID != "ec20_1" {
t.Fatalf("migrated SMS = %#v, %v", messages, err)
}
}
func TestMigration12ConvertsOnlyKnownActiveDeviceBindingToICCID(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "profile-proxy-binding.db")
raw, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
for version := 1; version <= 11; version++ {
for _, statement := range migrationStatements(version) {
if _, err := raw.ExecContext(ctx, statement); err != nil {
t.Fatalf("create v%d schema: %v", version, err)
}
}
}
if _, err := raw.ExecContext(ctx, `
INSERT INTO devices (id, name, created_at, updated_at) VALUES
('known', 'Known', 100, 100), ('unknown', 'Unknown', 100, 100);
INSERT INTO upstream_proxies (id, name, addr, created_at, updated_at)
VALUES ('route', 'Route', '127.0.0.1:1080', 100, 100);
INSERT INTO device_proxy_bindings (device_id, upstream_proxy_id, created_at, updated_at) VALUES
('known', 'route', 100, 100), ('unknown', 'route', 100, 100);
INSERT INTO vowifi_runtime (device_id, iccid, updated_at)
VALUES ('known', '89441000400128014257', 100);
PRAGMA user_version = 11;
`); err != nil {
t.Fatal(err)
}
if err := raw.Close(); err != nil {
t.Fatal(err)
}
database := openTestStore(t, path)
binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257")
if err != nil || binding.DeviceID != "known" || binding.UpstreamProxyID != "route" {
t.Fatalf("migrated binding = %+v, %v", binding, err)
}
bindings, err := database.ListDeviceProxyBindings(ctx)
if err != nil || len(bindings) != 1 {
t.Fatalf("migrated bindings = %+v, %v; unknown ICCID binding must be dropped", bindings, err)
}
}
func TestMigration9NormalizesVoWiFiAirplanePolicy(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "rf-safe-policy.db")
raw, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
for version := 1; version <= 8; version++ {
for _, statement := range migrationStatements(version) {
if _, err := raw.ExecContext(ctx, statement); err != nil {
t.Fatalf("create v%d schema: %v", version, err)
}
}
}
if _, err := raw.ExecContext(ctx, `
INSERT INTO card_policies (
iccid, network_enabled, vowifi_enabled, airplane_enabled,
created_at, updated_at
) VALUES ('8900000000000000001', 0, 1, 0, 100, 100);
PRAGMA user_version = 8;
`); err != nil {
t.Fatal(err)
}
if err := raw.Close(); err != nil {
t.Fatal(err)
}
database := openTestStore(t, path)
policy, err := database.CardPolicy(ctx, "8900000000000000001")
if err != nil {
t.Fatal(err)
}
if !policy.VoWiFiEnabled || !policy.AirplaneEnabled || policy.NetworkEnabled {
t.Fatalf("migrated policy = %#v, want VoWiFi+airplane with data off", policy)
}
}
func TestMigration8DefaultsExistingDevicesToPCIeType(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "device-type.db")
raw, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
for version := 1; version <= 7; version++ {
for _, statement := range migrationStatements(version) {
if _, err := raw.ExecContext(ctx, statement); err != nil {
t.Fatalf("create v%d schema: %v", version, err)
}
}
}
if _, err := raw.ExecContext(ctx, `
INSERT INTO devices (id, name, created_at, updated_at)
VALUES ('legacy', 'Legacy modem', 100, 100);
PRAGMA user_version = 7;
`); err != nil {
t.Fatal(err)
}
if err := raw.Close(); err != nil {
t.Fatal(err)
}
database := openTestStore(t, path)
got, err := database.Device(ctx, "legacy")
if err != nil {
t.Fatal(err)
}
if got.DeviceType != DeviceTypePCIeEC20EC25 {
t.Fatalf("legacy device type = %q", got.DeviceType)
}
}
func TestMigration4PreservesIMSRedeliveryAndUsesReceiptTime(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "ims-redelivery.db")
legacy := openTestStore(t, path)
mustSaveDevice(t, legacy, "ec20-1", "EC20")
smscTime := time.Unix(1_700_000_000, 0).UTC()
firstReceipt := smscTime.Add(2 * time.Hour)
rawTPDU := "040ED0D637396C7EBBCB000062808051715140"
for index, receivedAt := range []time.Time{firstReceipt, firstReceipt.Add(30 * time.Minute)} {
extra, err := json.Marshal(map[string]any{"raw_tpdu": rawTPDU, "call_id": index})
if err != nil {
t.Fatal(err)
}
if _, err := legacy.SaveSMSMessage(ctx, SMSMessage{
MessageID: fmt.Sprintf("legacy-call-%d", index),
DeviceID: "ec20-1",
Peer: "Vodafone",
Direction: "inbound",
Body: "same message",
Timestamp: smscTime,
Status: "received",
Source: "ims",
CreatedAt: receivedAt,
Extra: extra,
}); err != nil {
t.Fatal(err)
}
}
if _, err := legacy.db.ExecContext(ctx, `PRAGMA user_version = 3`); err != nil {
t.Fatal(err)
}
if err := legacy.Close(); err != nil {
t.Fatal(err)
}
migrated := openTestStore(t, path)
messages, err := migrated.ListSMSMessages(ctx, SMSFilter{DeviceID: "ec20-1"})
if err != nil {
t.Fatal(err)
}
if len(messages) != 2 {
t.Fatalf("message count after migration = %d, want 2", len(messages))
}
if !messages[0].Timestamp.Equal(firstReceipt.Add(30*time.Minute)) ||
!messages[1].Timestamp.Equal(firstReceipt) {
t.Fatalf("message times = %v / %v, want both receipt times", messages[0].Timestamp, messages[1].Timestamp)
}
var extra map[string]any
if err := json.Unmarshal(messages[0].Extra, &extra); err != nil {
t.Fatal(err)
}
if extra["service_center_timestamp_unix"] != float64(smscTime.Unix()) {
t.Fatalf("service center time was not retained: %#v", extra)
}
}
func TestDeviceStateRoundTripAndCascade(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
rsrp, rsrq, sinr := -95, -12, 15
attached, inserted := true, true
mode := 1
device := Device{
ID: "ec20-1",
Name: "EC20 一号",
DeviceType: DeviceTypeDJI4G,
Interface: "wwan0",
ControlDevice: "/dev/cdc-wdm0",
ATPort: "/dev/ttyUSB2",
APN: "ims",
ProxyPort: 1080,
QMIUseProxy: true,
NetworkEnabled: true,
SMSEnabled: true,
VoWiFiEnabled: true,
Extra: json.RawMessage(`{"slot":1}`),
}
runtime := DeviceRuntime{
Running: true,
Healthy: true,
ControlOnline: true,
NetworkConnected: true,
Operator: "China Mobile",
SignalDBM: -71,
SignalRSRP: &rsrp,
SignalRSRQ: &rsrq,
SignalSINR: &sinr,
ICCID: "8986000000000000000",
IMSI: "460001234567890",
PSAttached: &attached,
SIMInserted: &inserted,
OperatingMode: &mode,
PhoneNumber: "+8613800138000",
PhoneNumberSource: "cnum",
Traffic: json.RawMessage(`{"rx":"1 MiB"}`),
}
vowifi := VoWiFiRuntime{
Phase: "sms_ready",
SIMReady: true,
AccessReady: true,
TunnelReady: true,
IMSReady: true,
SMSReady: true,
LocalPhone: "+8613800138000",
PhoneNumberSource: "ims",
Tunnel: json.RawMessage(`{"ifname":"ipsec0"}`),
}
if err := database.SaveDeviceState(ctx, device, &runtime, &vowifi); err != nil {
t.Fatalf("SaveDeviceState() error = %v", err)
}
gotDevice, err := database.Device(ctx, device.ID)
if err != nil {
t.Fatal(err)
}
if gotDevice.DeviceType != DeviceTypeDJI4G || gotDevice.BaudRate != 115200 || gotDevice.DataBits != 8 ||
gotDevice.StopBits != 1 || gotDevice.DeviceBackend != "at" {
t.Fatalf("device defaults not applied: %+v", gotDevice)
}
gotRuntime, err := database.DeviceRuntime(ctx, device.ID)
if err != nil {
t.Fatal(err)
}
if gotRuntime.PhoneNumber != runtime.PhoneNumber ||
gotRuntime.SignalRSRP == nil || *gotRuntime.SignalRSRP != rsrp ||
gotRuntime.PSAttached == nil || !*gotRuntime.PSAttached {
t.Fatalf("runtime did not round trip: %+v", gotRuntime)
}
gotVoWiFi, err := database.VoWiFiRuntime(ctx, device.ID)
if err != nil {
t.Fatal(err)
}
if !gotVoWiFi.SMSReady || gotVoWiFi.LocalPhone != vowifi.LocalPhone {
t.Fatalf("VoWiFi runtime did not round trip: %+v", gotVoWiFi)
}
if err := database.DeleteDevice(ctx, device.ID); err != nil {
t.Fatal(err)
}
if _, err := database.DeviceRuntime(ctx, device.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("runtime should cascade on device deletion, got %v", err)
}
if _, err := database.VoWiFiRuntime(ctx, device.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("VoWiFi runtime should cascade on device deletion, got %v", err)
}
}
func TestSMSPersistenceAndDerivedThreads(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
mustSaveDevice(t, database, "ec20-1", "客厅")
if err := database.UpsertDeviceRuntime(ctx, DeviceRuntime{
DeviceID: "ec20-1",
PhoneNumber: "+8613800138000",
}); err != nil {
t.Fatal(err)
}
base := time.Unix(1_700_000_000, 0).UTC()
if err := database.SaveSMSMessages(ctx, []SMSMessage{
{
MessageID: "network-1", DeviceID: "ec20-1", IMSI: "46000",
Peer: "10086", Direction: "inbound", Body: "第一条",
Timestamp: base, Status: "received",
},
{
MessageID: "network-2", DeviceID: "ec20-1", IMSI: "46000",
Peer: "10086", Direction: "outbound", Body: "第二条",
Timestamp: base.Add(time.Minute), Status: "sent", Read: true,
},
{
MessageID: "network-3", DeviceID: "ec20-1", IMSI: "46000",
Peer: "95533", Direction: "received", Body: "银行提醒",
Timestamp: base.Add(2 * time.Minute), Status: "received",
},
}); err != nil {
t.Fatalf("SaveSMSMessages() error = %v", err)
}
// A modem retry updates the stable external id instead of duplicating it.
if _, err := database.SaveSMSMessage(ctx, SMSMessage{
MessageID: "network-1", DeviceID: "ec20-1", IMSI: "46000",
Peer: "10086", Direction: "inbound", Body: "第一条(完整)",
Timestamp: base, Status: "received",
}); err != nil {
t.Fatal(err)
}
messages, err := database.ListSMSMessages(ctx, SMSFilter{DeviceID: "ec20-1"})
if err != nil {
t.Fatal(err)
}
if len(messages) != 3 {
t.Fatalf("message count = %d, want 3", len(messages))
}
if !messages[2].Timestamp.Equal(base) {
t.Fatalf("retry changed the original message time to %v", messages[2].Timestamp)
}
contacts, err := database.ListSMSContacts(ctx, SMSFilter{DeviceID: "ec20-1"})
if err != nil {
t.Fatal(err)
}
if len(contacts) != 2 || contacts[0].Peer != "95533" ||
contacts[0].UnreadCount != 1 || contacts[1].Peer != "10086" ||
contacts[1].MessageCount != 2 || contacts[1].UnreadCount != 1 ||
contacts[1].LocalPhone != "+8613800138000" {
t.Fatalf("unexpected derived contacts: %+v", contacts)
}
marked, err := database.MarkSMSThreadRead(ctx, "ec20-1", "46000", "10086")
if err != nil || marked != 1 {
t.Fatalf("MarkSMSThreadRead() = %d, %v", marked, err)
}
contacts, err = database.ListSMSContacts(ctx, SMSFilter{Peer: "10086"})
if err != nil {
t.Fatal(err)
}
if len(contacts) != 1 || contacts[0].UnreadCount != 0 {
t.Fatalf("thread should be read: %+v", contacts)
}
deleted, err := database.DeleteSMSThread(ctx, "ec20-1", "46000", "10086")
if err != nil || deleted != 2 {
t.Fatalf("DeleteSMSThread() = %d, %v", deleted, err)
}
}
func TestSMSHistoryFollowsModemIMEIAfterDeviceIDRename(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
const imei = "867394042309830"
if err := database.UpsertDevice(ctx, Device{
ID: "ec20_1", Name: "EC20 old", ModemIMEI: imei,
}); err != nil {
t.Fatal(err)
}
if _, err := database.SaveSMSMessage(ctx, SMSMessage{
MessageID: "network-old", DeviceID: "ec20_1",
IMSI: "23415", Peer: "VOXI", Direction: "inbound", Body: "before rename",
Timestamp: time.Unix(1_700_000_000, 0).UTC(),
}); err != nil {
t.Fatal(err)
}
if err := database.DeleteDevice(ctx, "ec20_1"); err != nil {
t.Fatal(err)
}
if err := database.UpsertDevice(ctx, Device{
ID: "ec20_2", Name: "EC20 renamed", ModemIMEI: imei,
}); err != nil {
t.Fatal(err)
}
if _, err := database.SaveSMSMessage(ctx, SMSMessage{
MessageID: "network-new", DeviceID: "ec20_2", ModemIMEI: imei,
IMSI: "23415", Peer: "VOXI", Direction: "inbound", Body: "after rename",
Timestamp: time.Unix(1_700_000_060, 0).UTC(),
}); err != nil {
t.Fatal(err)
}
contacts, err := database.ListSMSContacts(ctx, SMSFilter{ModemIMEI: imei})
if err != nil {
t.Fatal(err)
}
if len(contacts) != 1 || contacts[0].DeviceID != "ec20_2" ||
contacts[0].ModemIMEI != imei || contacts[0].MessageCount != 2 {
t.Fatalf("renamed hardware contact = %#v", contacts)
}
messages, err := database.ListSMSMessages(ctx, SMSFilter{
ModemIMEI: imei, IMSI: "23415", Peer: "VOXI",
})
if err != nil || len(messages) != 2 {
t.Fatalf("renamed hardware messages = %#v, %v", messages, err)
}
// A retry that arrives after the rename updates the same hardware message,
// rather than duplicating it under the new configured ID.
if _, err := database.SaveSMSMessage(ctx, SMSMessage{
MessageID: "network-old", DeviceID: "ec20_2", ModemIMEI: imei,
IMSI: "23415", Peer: "VOXI", Direction: "inbound", Body: "retry",
Timestamp: time.Unix(1_700_000_000, 0).UTC(),
}); err != nil {
t.Fatal(err)
}
messages, err = database.ListSMSMessages(ctx, SMSFilter{ModemIMEI: imei})
if err != nil || len(messages) != 2 {
t.Fatalf("retry after rename messages = %#v, %v", messages, err)
}
}
func TestListInboundSMSAfterIDUsesDurableInsertionCursor(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
mustSaveDevice(t, database, "ec20-1", "EC20")
old, err := database.SaveSMSMessage(ctx, SMSMessage{
MessageID: "old-inbound", DeviceID: "ec20-1", Peer: "10086",
Direction: "inbound", Body: "old", Status: "received",
})
if err != nil {
t.Fatal(err)
}
if _, err := database.SaveSMSMessage(ctx, SMSMessage{
MessageID: "new-outbound", DeviceID: "ec20-1", Peer: "10010",
Direction: "outbound", Body: "sent", Status: "sent",
}); err != nil {
t.Fatal(err)
}
newInbound, err := database.SaveSMSMessage(ctx, SMSMessage{
MessageID: "new-inbound", DeviceID: "ec20-1", Peer: "95533",
Direction: "received", Body: "new", Status: "received",
})
if err != nil {
t.Fatal(err)
}
latest, err := database.LatestSMSMessageID(ctx)
if err != nil || latest != newInbound.ID {
t.Fatalf("LatestSMSMessageID() = %d, %v; want %d", latest, err, newInbound.ID)
}
messages, err := database.ListInboundSMSAfterID(ctx, old.ID, 100)
if err != nil {
t.Fatal(err)
}
if len(messages) != 1 || messages[0].ID != newInbound.ID {
t.Fatalf("ListInboundSMSAfterID() = %#v", messages)
}
}
func TestApplySMSDeliveryReportTracksEverySubmittedPart(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
mustSaveDevice(t, database, "ec20-1", "EC20")
extra := json.RawMessage(`{
"transport":"ims",
"part_results":[{"reference":42},{"reference":43}]
}`)
sent, err := database.SaveSMSMessage(ctx, SMSMessage{
MessageID: "ims-submit-1", DeviceID: "ec20-1", IMSI: "23415",
Peer: "+447700900123", Direction: "outbound", Body: "multipart",
Timestamp: time.Now().UTC(), Status: "accepted_by_ims", Source: "ims",
PartsTotal: 2, DeliveryState: "accepted_by_ims", Read: true, Extra: extra,
})
if err != nil {
t.Fatal(err)
}
first, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{
DeviceID: "ec20-1", IMSI: "23415", Peer: "+447700900123", Source: "ims",
MessageReference: 42, StatusCode: 0, DeliveryState: "delivered",
})
if err != nil || first.ID != sent.ID || first.DeliveryState != "pending_delivery_report" {
t.Fatalf("first delivery report = (%#v, %v)", first, err)
}
second, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{
DeviceID: "ec20-1", IMSI: "23415", Peer: "+447700900123", Source: "ims",
MessageReference: 43, StatusCode: 0, DeliveryState: "delivered",
})
if err != nil || second.ID != sent.ID || second.DeliveryState != "delivered" {
t.Fatalf("second delivery report = (%#v, %v)", second, err)
}
var savedExtra map[string]any
if err := json.Unmarshal(second.Extra, &savedExtra); err != nil {
t.Fatal(err)
}
reports, _ := savedExtra["delivery_reports"].(map[string]any)
if len(reports) != 2 {
t.Fatalf("delivery reports = %#v", reports)
}
}
func TestProxyCredentialsAndCountryRules(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
mustSaveDevice(t, database, "ec20-1", "EC20")
if err := database.UpsertLocalProxy(ctx, LocalProxyConfig{
ID: "local-1", Name: "SOCKS", Mode: "socks5", DeviceID: "ec20-1",
ListenAddr: "127.0.0.1", ListenPort: 1080, Enabled: true,
AuthEnabled: true, Username: "user", Password: "local-secret",
}); err != nil {
t.Fatal(err)
}
if err := database.UpsertLocalProxy(ctx, LocalProxyConfig{
ID: "local-1", Name: "SOCKS 新", Mode: "socks5", DeviceID: "ec20-1",
ListenAddr: "127.0.0.1", ListenPort: 1080, Enabled: true,
AuthEnabled: true, Username: "user", Password: "",
}); err != nil {
t.Fatal(err)
}
local, err := database.LocalProxy(ctx, "local-1")
if err != nil {
t.Fatal(err)
}
if local.Password != "local-secret" || local.Redacted().Password != SecretMask ||
local.Public().Password != "" {
t.Fatalf("local proxy credential semantics failed: %+v", local)
}
if err := database.UpsertUpstreamProxy(ctx, UpstreamProxy{
ID: "up-1", Name: "上游", Addr: "127.0.0.1:2080",
Username: "up-user", Password: "up-secret", Enabled: true,
}); err != nil {
t.Fatal(err)
}
if err := database.UpsertUpstreamProxy(ctx, UpstreamProxy{
ID: "up-1", Name: "上游新", Addr: "127.0.0.1:2080",
Username: "up-user", Password: SecretMask, Enabled: true,
}); err != nil {
t.Fatal(err)
}
upstream, err := database.UpstreamProxy(ctx, "up-1")
if err != nil {
t.Fatal(err)
}
if upstream.Password != "up-secret" {
t.Fatalf("blank/masked update erased upstream secret: %+v", upstream)
}
if got := RedactText(
"connect local-secret through up-secret",
local,
upstream,
); strings.Contains(got, "secret") {
t.Fatalf("RedactText leaked credentials: %q", got)
}
if err := database.UpsertCountryRule(ctx, CountryRule{
CountryCode: "cn", CountryName: "中国", UpstreamProxyID: "up-1",
Enabled: true,
}); err != nil {
t.Fatal(err)
}
rule, err := database.CountryRule(ctx, "CN")
if err != nil || rule.CountryCode != "CN" {
t.Fatalf("CountryRule() = %+v, %v", rule, err)
}
if err := database.UpsertDeviceProxyBinding(ctx, DeviceProxyBinding{
DeviceID: "ec20-1", ICCID: "89441000400128014257", ProfileName: "Vodafone", UpstreamProxyID: "up-1",
}); err != nil {
t.Fatal(err)
}
binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257")
if err != nil || binding.UpstreamProxyID != "up-1" || binding.DeviceID != "ec20-1" || binding.ProfileName != "Vodafone" {
t.Fatalf("DeviceProxyBinding() = %+v, %v", binding, err)
}
if err := database.DeleteUpstreamProxy(ctx, "up-1"); err != nil {
t.Fatal(err)
}
if _, err := database.CountryRule(ctx, "CN"); !errors.Is(err, ErrNotFound) {
t.Fatalf("country rule should cascade with upstream deletion, got %v", err)
}
if _, err := database.DeviceProxyBinding(ctx, "89441000400128014257"); !errors.Is(err, ErrNotFound) {
t.Fatalf("device binding should cascade with upstream deletion, got %v", err)
}
}
func TestNotificationAndAppSecretPreservation(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
if err := database.SaveNotificationSettings(ctx, []NotificationSetting{
{
Channel: "email",
Config: json.RawMessage(`{"password":"mail-secret"}`),
},
{
Channel: "webhook",
Config: json.RawMessage(`not-json`),
},
}); err == nil {
t.Fatal("invalid notification batch was accepted")
}
if _, err := database.NotificationSetting(ctx, "email"); !errors.Is(err, ErrNotFound) {
t.Fatalf("notification batch was not rolled back: %v", err)
}
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
Channel: "telegram",
Enabled: true,
Config: json.RawMessage(`{"bot_token":"telegram-secret","chat_id":"1"}`),
}); err != nil {
t.Fatal(err)
}
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
Channel: "telegram",
Enabled: true,
Config: json.RawMessage(`{"bot_token":"","chat_id":"2"}`),
}); err != nil {
t.Fatal(err)
}
setting, err := database.NotificationSetting(ctx, "telegram")
if err != nil {
t.Fatal(err)
}
var config map[string]any
if err := json.Unmarshal(setting.Config, &config); err != nil {
t.Fatal(err)
}
if config["bot_token"] != "telegram-secret" || config["chat_id"] != "2" {
t.Fatalf("notification merge lost data: %s", setting.Config)
}
if bytes.Contains(setting.Redacted().Config, []byte("telegram-secret")) ||
bytes.Contains(setting.Public().Config, []byte("telegram-secret")) {
t.Fatal("notification views leaked secret")
}
if got := RedactText("token=telegram-secret", setting); strings.Contains(got, "telegram-secret") {
t.Fatalf("notification secret leaked in text: %q", got)
}
if err := database.UpsertAppSetting(ctx, AppSetting{
Key: "provider.token", Value: json.RawMessage(`"app-secret"`), Sensitive: true,
}); err != nil {
t.Fatal(err)
}
if err := database.UpsertAppSetting(ctx, AppSetting{
Key: "provider.token", Value: json.RawMessage(`"********"`), Sensitive: true,
}); err != nil {
t.Fatal(err)
}
appSetting, err := database.AppSetting(ctx, "provider.token")
if err != nil {
t.Fatal(err)
}
if string(appSetting.Value) != `"app-secret"` ||
string(appSetting.Redacted().Value) != `"********"` ||
string(appSetting.Public().Value) != `null` {
t.Fatalf("unexpected sensitive app setting: %+v", appSetting)
}
}
func TestEventsPoliciesAndTraffic(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
old := time.Unix(1_700_000_000, 0).UTC()
recent := old.Add(time.Hour)
if _, err := database.AppendAuditEvent(ctx, AuditEvent{
Actor: "admin", Action: "device.update", EntityType: "device",
EntityID: "ec20-1", Outcome: "ok", CreatedAt: old,
}); err != nil {
t.Fatal(err)
}
if _, err := database.AppendAuditEvent(ctx, AuditEvent{
Actor: "system", Action: "device.refresh", EntityType: "device",
EntityID: "ec20-1", Outcome: "ok", CreatedAt: recent,
}); err != nil {
t.Fatal(err)
}
audits, err := database.ListAuditEvents(ctx, AuditFilter{Actor: "admin"})
if err != nil || len(audits) != 1 || audits[0].Action != "device.update" {
t.Fatalf("audit filter result = %+v, %v", audits, err)
}
if _, err := database.AppendLogEvent(ctx, LogEvent{
Time: old, Level: "warn", Message: "old warning",
Fields: json.RawMessage(`{"device":"ec20-1"}`),
}); err != nil {
t.Fatal(err)
}
if _, err := database.AppendLogEvent(ctx, LogEvent{
Time: recent, Level: "info", Message: "ready",
}); err != nil {
t.Fatal(err)
}
logs, err := database.ListLogEvents(ctx, LogFilter{Level: "info"})
if err != nil || len(logs) != 1 || logs[0].Message != "ready" {
t.Fatalf("log filter result = %+v, %v", logs, err)
}
auditDeleted, logDeleted, err := database.PruneEvents(
ctx,
old.Add(time.Minute),
old.Add(time.Minute),
)
if err != nil || auditDeleted != 1 || logDeleted != 1 {
t.Fatalf("PruneEvents() = %d, %d, %v", auditDeleted, logDeleted, err)
}
if err := database.UpsertCardPolicy(ctx, CardPolicy{
ICCID: "89860001", NetworkEnabled: true, VoWiFiEnabled: true,
APN: "ims", IPVersion: "ipv4v6", CustomPhoneNumber: "+8613800138000",
}); err != nil {
t.Fatal(err)
}
if err := database.UpsertCardPolicy(ctx, CardPolicy{
ICCID: "89860002", VoWiFiEnabled: true, AirplaneEnabled: true,
}); err != nil {
t.Fatalf("RF-safe VoWiFi policy was rejected: %v", err)
}
policy, err := database.CardPolicy(ctx, "89860001")
if err != nil || !policy.VoWiFiEnabled || policy.CustomPhoneNumber != "+8613800138000" {
t.Fatalf("CardPolicy() = %+v, %v", policy, err)
}
safePolicy, err := database.CardPolicy(ctx, "89860002")
if err != nil || !safePolicy.VoWiFiEnabled || !safePolicy.AirplaneEnabled {
t.Fatalf("safe CardPolicy() = %+v, %v", safePolicy, err)
}
period := old.Truncate(time.Hour)
if err := database.UpsertTrafficBucket(ctx, TrafficBucket{
DeviceID: "ec20-1", Bucket: "hour", PeriodStart: period,
RXBytes: 100, TXBytes: 25,
}); err != nil {
t.Fatal(err)
}
if err := database.AddTrafficBucket(ctx, TrafficBucket{
DeviceID: "ec20-1", Bucket: "hour", PeriodStart: period,
RXBytes: 5, TXBytes: 10,
}); err != nil {
t.Fatal(err)
}
buckets, err := database.ListTrafficBuckets(ctx, TrafficFilter{
DeviceID: "ec20-1", Bucket: "hour",
})
if err != nil || len(buckets) != 1 ||
buckets[0].RXBytes != 105 || buckets[0].TXBytes != 35 ||
buckets[0].TotalBytes() != 140 {
t.Fatalf("traffic buckets = %+v, %v", buckets, err)
}
}
func openTestStore(t *testing.T, path string) *Store {
t.Helper()
database, err := Open(context.Background(), path)
if err != nil {
t.Fatalf("Open(%q) error = %v", path, err)
}
t.Cleanup(func() {
if err := database.Close(); err != nil {
t.Errorf("Close() error = %v", err)
}
})
return database
}
func mustSaveDevice(t *testing.T, database *Store, id, name string) {
t.Helper()
if err := database.UpsertDevice(context.Background(), Device{
ID: id, Name: name, SMSEnabled: true,
}); err != nil {
t.Fatalf("UpsertDevice() error = %v", err)
}
}