mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
766 lines
25 KiB
Go
766 lines
25 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", "traffic_buckets",
|
|
} {
|
|
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 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", UpstreamProxyID: "up-1",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
binding, err := database.DeviceProxyBinding(ctx, "ec20-1")
|
|
if err != nil || binding.UpstreamProxyID != "up-1" {
|
|
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, "ec20-1"); !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",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := database.UpsertCardPolicy(ctx, CardPolicy{
|
|
ICCID: "invalid", VoWiFiEnabled: true, AirplaneEnabled: true,
|
|
}); err == nil {
|
|
t.Fatal("invalid mutually exclusive card policy was accepted")
|
|
}
|
|
policy, err := database.CardPolicy(ctx, "89860001")
|
|
if err != nil || !policy.VoWiFiEnabled {
|
|
t.Fatalf("CardPolicy() = %+v, %v", policy, 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)
|
|
}
|
|
}
|