chore: initialize go-sip repository

This commit is contained in:
2026-09-21 08:56:04 +08:00
commit 643b11b21f
309 changed files with 40521 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
// Package agent contains file-backed Agent execution state and local media
// asset lifecycle. Network session/authentication implementations are added
// only against the published internal Proto contract; no JSON substitute is
// used here.
package agent
+72
View File
@@ -0,0 +1,72 @@
package agent
import (
"encoding/json"
"errors"
"fmt"
"time"
"git.ipao.vip/rogee/go-sip/internal/contract"
)
// EventWriter keeps Agent-produced realtime facts in the approved event
// vocabulary before they are handed to Dispatcher/MQ. Transcript text is
// written to the Agent spool as an archive as well as returned for realtime
// publication; the archive is not a substitute for transcript.updated.
type EventWriter struct {
TenantID string
TenantKey string
TraceID string
}
func (w EventWriter) TranscriptUpdated(now time.Time, eventID, callID, turnID, segmentID, role, text string, revision int64, final bool, startMS, endMS int64) ([]byte, error) {
if eventID == "" || callID == "" || turnID == "" || segmentID == "" || role == "" {
return nil, errors.New("transcript event identity is required")
}
if revision < 1 || startMS < 0 || endMS < startMS {
return nil, errors.New("transcript timing or revision is invalid")
}
return (contract.EventBuilder{
TenantID: w.TenantID, TenantKey: w.TenantKey, TraceID: w.TraceID,
EventType: "transcript.updated", Aggregate: "transcript_segment", AggregateID: segmentID, Version: revision,
Payload: map[string]any{
"call_id": callID, "turn_id": turnID, "segment_id": segmentID,
"role": role, "revision": revision, "text": text, "is_final": final,
"start_ms": startMS, "end_ms": endMS, "playback_state": "not_applicable",
},
}).Marshal(now, eventID)
}
func (w EventWriter) RecordingReady(now time.Time, eventID, callID, recordingID, ossID, format string, channels int32, sampleRateHz int32, durationMS, sizeBytes int64, checksum string) ([]byte, error) {
if eventID == "" || callID == "" || recordingID == "" || ossID == "" || checksum == "" {
return nil, errors.New("verified recording identity and checksum are required")
}
if sizeBytes < 1 || durationMS < 0 || channels != 1 || sampleRateHz < 8000 {
return nil, errors.New("recording metadata is invalid")
}
return (contract.EventBuilder{
TenantID: w.TenantID, TenantKey: w.TenantKey, TraceID: w.TraceID,
EventType: "recording.ready", Aggregate: "recording", AggregateID: recordingID, Version: 1,
Payload: map[string]any{
"call_id": callID, "recording_id": recordingID, "oss_id": ossID,
"format": format, "channels": channels, "sample_rate_hz": sampleRateHz,
"duration_ms": durationMS, "size_bytes": sizeBytes, "checksum_sha256": checksum,
},
}).Marshal(now, eventID)
}
func (s *Spool) AppendApprovedEvent(executionID string, event []byte) error {
var envelope struct {
EventType string `json:"event_type"`
}
if err := json.Unmarshal(event, &envelope); err != nil {
return fmt.Errorf("decode event envelope: %w", err)
}
if envelope.EventType != "transcript.updated" {
return fmt.Errorf("Agent transcript archive accepts transcript.updated only, got %q", envelope.EventType)
}
if err := contract.ValidateEvent(event); err != nil {
return err
}
return s.AppendTranscript(executionID, event)
}
+51
View File
@@ -0,0 +1,51 @@
package agent
import (
"strings"
"testing"
"time"
"git.ipao.vip/rogee/go-sip/internal/contract"
)
func TestEventWriterBuildsApprovedRealtimeTranscript(t *testing.T) {
writer := EventWriter{TenantID: "tenant-1", TenantKey: "tenant-demo-key", TraceID: "trace-1"}
event, err := writer.TranscriptUpdated(time.Unix(100, 0), "event-1", "call-1", "turn-1", "segment-1", "customer", "您好", 1, true, 0, 600)
if err != nil {
t.Fatal(err)
}
if err := contract.ValidateEvent(event); err != nil {
t.Fatal(err)
}
if string(event) == "" || strings.Contains(string(event), "call.transcript") {
t.Fatal("invalid transcript alias or empty event")
}
spool, err := NewSpool(t.TempDir(), time.Now)
if err != nil {
t.Fatal(err)
}
if _, err := spool.Start("execution-1", 1, "session-1"); err != nil {
t.Fatal(err)
}
if err := spool.AppendApprovedEvent("execution-1", event); err != nil {
t.Fatal(err)
}
}
func TestEventWriterRejectsUnverifiedRecordingAndWrongArchiveEvent(t *testing.T) {
writer := EventWriter{TenantID: "tenant-1", TenantKey: "tenant-demo-key", TraceID: "trace-1"}
if _, err := writer.RecordingReady(time.Unix(100, 0), "event-1", "call-1", "recording-1", "", "wav", 1, 16000, 1000, 100, strings.Repeat("a", 64)); err == nil {
t.Fatal("expected missing OSS verification ID to be rejected")
}
spool, err := NewSpool(t.TempDir(), time.Now)
if err != nil {
t.Fatal(err)
}
if _, err := spool.Start("execution-1", 1, "session-1"); err != nil {
t.Fatal(err)
}
if err := spool.AppendApprovedEvent("execution-1", []byte(`{"event_type":"call.transcript"}`)); err == nil {
t.Fatal("expected invalid realtime event name to be rejected")
}
}
+275
View File
@@ -0,0 +1,275 @@
// Package agent owns the Agent's file-backed execution and asset recovery
// state. It deliberately has no business database and never proxies audio to
// Dispatcher.
package agent
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
type State struct {
SchemaVersion string `json:"schema_version"`
ExecutionID string `json:"execution_id"`
TaskRevision int64 `json:"task_revision"`
SessionID string `json:"session_id,omitempty"`
Status string `json:"status"`
Unknown bool `json:"unknown"`
Reason string `json:"reason,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type Spool struct {
root string
now func() time.Time
mu sync.Mutex
}
func NewSpool(root string, now func() time.Time) (*Spool, error) {
if strings.TrimSpace(root) == "" {
return nil, errors.New("spool root is required")
}
if now == nil {
now = time.Now
}
if err := os.MkdirAll(root, 0o700); err != nil {
return nil, fmt.Errorf("create spool root: %w", err)
}
return &Spool{root: root, now: now}, nil
}
func (s *Spool) Root() string { return s.root }
func (s *Spool) Start(executionID string, revision int64, sessionID string) (State, error) {
if err := validateName(executionID); err != nil {
return State{}, err
}
if revision < 1 {
return State{}, errors.New("task revision must be positive")
}
state := State{SchemaVersion: "1", ExecutionID: executionID, TaskRevision: revision, SessionID: sessionID, Status: "reserved", UpdatedAt: s.now().UTC()}
s.mu.Lock()
defer s.mu.Unlock()
path := s.statePath(executionID)
if _, err := os.Stat(path); err == nil {
return State{}, fmt.Errorf("execution state already exists: %s", executionID)
} else if !errors.Is(err, os.ErrNotExist) {
return State{}, err
}
for _, dir := range []string{s.executionDir(executionID), filepath.Join(s.executionDir(executionID), "transcript"), filepath.Join(s.executionDir(executionID), "assets")} {
if err := os.MkdirAll(dir, 0o700); err != nil {
return State{}, err
}
}
if err := writeJSONAtomic(path, state); err != nil {
return State{}, err
}
return state, nil
}
func (s *Spool) Load(executionID string) (State, error) {
if err := validateName(executionID); err != nil {
return State{}, err
}
data, err := os.ReadFile(s.statePath(executionID))
if err != nil {
return State{}, err
}
var state State
if err := json.Unmarshal(data, &state); err != nil {
return State{}, fmt.Errorf("decode execution state: %w", err)
}
return state, nil
}
func (s *Spool) Update(executionID, status, reason string) (State, error) {
if err := validateName(executionID); err != nil {
return State{}, err
}
if status == "" {
return State{}, errors.New("state status is required")
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := os.ReadFile(s.statePath(executionID))
if err != nil {
return State{}, err
}
var state State
if err := json.Unmarshal(data, &state); err != nil {
return State{}, fmt.Errorf("decode execution state: %w", err)
}
state.Status, state.Reason, state.UpdatedAt = status, reason, s.now().UTC()
state.Unknown = status == "unknown"
if err := writeJSONAtomic(s.statePath(executionID), state); err != nil {
return State{}, err
}
return state, nil
}
// MarkUnknownOnBoot converts all in-flight local state to explicit unknown.
// It never deletes or releases a remote reservation; Dispatcher reconciliation
// must decide whether a recovered execution may proceed.
func (s *Spool) MarkUnknownOnBoot() (RecoveryReport, error) {
s.mu.Lock()
defer s.mu.Unlock()
entries, err := os.ReadDir(s.root)
if err != nil {
return RecoveryReport{}, err
}
var report RecoveryReport
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
executionID := entry.Name()
path := s.statePath(executionID)
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return report, err
}
var state State
if err := json.Unmarshal(data, &state); err != nil {
quarantine := path + ".corrupt-" + s.now().UTC().Format("20060102T150405.000000000Z")
if renameErr := os.Rename(path, quarantine); renameErr != nil {
return report, fmt.Errorf("quarantine corrupt state: %w (decode: %v)", renameErr, err)
}
report.Quarantined = append(report.Quarantined, executionID)
continue
}
if state.Status != "running" && state.Status != "reserved" && state.Status != "starting" && state.Status != "draining" {
continue
}
state.Status, state.Unknown, state.Reason, state.UpdatedAt = "unknown", true, "agent_boot_recovery", s.now().UTC()
if err := writeJSONAtomic(path, state); err != nil {
return report, err
}
report.Unknown = append(report.Unknown, executionID)
}
return report, nil
}
type RecoveryReport struct {
Unknown []string
Quarantined []string
}
func (s *Spool) AppendTranscript(executionID string, event []byte) error {
if err := validateName(executionID); err != nil {
return err
}
if len(event) == 0 {
return errors.New("transcript event is empty")
}
if !json.Valid(event) {
return errors.New("transcript event must be valid JSON")
}
path := filepath.Join(s.executionDir(executionID), "transcript", "events.jsonl")
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
if err != nil {
return err
}
defer file.Close()
if _, err := file.Write(append(event, '\n')); err != nil {
return err
}
return file.Sync()
}
func (s *Spool) WriteAsset(executionID, assetID string, r io.Reader) (string, int64, string, error) {
if err := validateName(executionID); err != nil {
return "", 0, "", err
}
if err := validateName(assetID); err != nil {
return "", 0, "", err
}
if r == nil {
return "", 0, "", errors.New("asset reader is required")
}
dir := filepath.Join(s.executionDir(executionID), "assets")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", 0, "", err
}
part := filepath.Join(dir, assetID+".part")
final := filepath.Join(dir, assetID)
file, err := os.OpenFile(part, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return "", 0, "", err
}
hash := sha256.New()
n, copyErr := io.Copy(io.MultiWriter(file, hash), r)
syncErr := file.Sync()
closeErr := file.Close()
if copyErr != nil || syncErr != nil || closeErr != nil {
_ = os.Remove(part)
return "", n, "", firstError(copyErr, syncErr, closeErr)
}
if err := os.Rename(part, final); err != nil {
_ = os.Remove(part)
return "", n, "", err
}
return final, n, hex.EncodeToString(hash.Sum(nil)), nil
}
func (s *Spool) executionDir(executionID string) string { return filepath.Join(s.root, executionID) }
func (s *Spool) statePath(executionID string) string {
return filepath.Join(s.executionDir(executionID), "state.json")
}
func writeJSONAtomic(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil {
return err
}
file, err := os.OpenFile(tmp, os.O_RDWR, 0o600)
if err != nil {
_ = os.Remove(tmp)
return err
}
if err := file.Sync(); err != nil {
_ = file.Close()
_ = os.Remove(tmp)
return err
}
if err := file.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
func validateName(name string) error {
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\\`) || strings.Contains(name, "..") || strings.TrimSpace(name) != name {
return fmt.Errorf("unsafe file name %q", name)
}
return nil
}
func firstError(errs ...error) error {
for _, err := range errs {
if err != nil {
return err
}
}
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package agent
import (
"bytes"
"os"
"path/filepath"
"testing"
"time"
)
func testSpool(t *testing.T) *Spool {
t.Helper()
now := time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC)
s, err := NewSpool(t.TempDir(), func() time.Time { return now })
if err != nil {
t.Fatal(err)
}
return s
}
func TestSpoolAtomicStateAndBootUnknown(t *testing.T) {
s := testSpool(t)
if _, err := s.Start("exec-1", 1, "session-1"); err != nil {
t.Fatal(err)
}
if _, err := s.Update("exec-1", "running", ""); err != nil {
t.Fatal(err)
}
report, err := s.MarkUnknownOnBoot()
if err != nil {
t.Fatal(err)
}
if len(report.Unknown) != 1 || report.Unknown[0] != "exec-1" {
t.Fatalf("recovery report = %+v", report)
}
state, err := s.Load("exec-1")
if err != nil {
t.Fatal(err)
}
if state.Status != "unknown" || !state.Unknown {
t.Fatalf("state = %+v", state)
}
}
func TestSpoolQuarantinesCorruptStateAndNeverDeletesIt(t *testing.T) {
s := testSpool(t)
if err := os.MkdirAll(filepath.Join(s.Root(), "broken"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(s.Root(), "broken", "state.json"), []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
report, err := s.MarkUnknownOnBoot()
if err != nil {
t.Fatal(err)
}
if len(report.Quarantined) != 1 || len(report.Unknown) != 0 {
t.Fatalf("recovery report = %+v", report)
}
matches, err := filepath.Glob(filepath.Join(s.Root(), "broken", "state.json.corrupt-*"))
if err != nil || len(matches) != 1 {
t.Fatalf("quarantine files = %v, err=%v", matches, err)
}
}
func TestSpoolTranscriptAndAssetAreDurableFiles(t *testing.T) {
s := testSpool(t)
if _, err := s.Start("exec-2", 1, "session-2"); err != nil {
t.Fatal(err)
}
if err := s.AppendTranscript("exec-2", []byte(`{"text":"hello","final":true}`)); err != nil {
t.Fatal(err)
}
path, n, hash, err := s.WriteAsset("exec-2", "recording.pcm", bytes.NewReader([]byte("pcm")))
if err != nil {
t.Fatal(err)
}
if n != 3 || hash == "" || path == "" {
t.Fatalf("asset result path=%q bytes=%d hash=%q", path, n, hash)
}
if _, err := os.Stat(filepath.Join(s.Root(), "exec-2", "assets", "recording.pcm.part")); !os.IsNotExist(err) {
t.Fatalf("temporary asset still exists: %v", err)
}
}
+154
View File
@@ -0,0 +1,154 @@
package agent
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
)
// UploadClient performs the Agent-direct data-plane upload using a restricted
// Dispatcher grant. It never sends file bytes through Dispatcher or writes an
// OSS credential to logs. It never deletes or moves the source asset; the
// lifecycle owner retains it until the verified handoff is durably recorded.
type UploadClient struct {
HTTPClient *http.Client
AllowedHosts map[string]struct{}
AllowInsecureHTTP bool
MaxResponseBodySize int64
Now func() time.Time
}
var ErrUploadGrantExpired = errors.New("upload grant is expired")
type UploadResult struct {
StatusCode int
SizeBytes int64
SHA256 string
ETag string
}
func (c UploadClient) UploadFile(ctx context.Context, grant *agentv1.UploadGrant, path string) (result UploadResult, err error) {
if grant == nil {
return UploadResult{}, errors.New("upload grant is required")
}
if grant.TargetUrl == "" || grant.UploadId == "" || grant.ObjectKey == "" {
return UploadResult{}, errors.New("upload URL, ID and object key are required")
}
if grant.ExpiresAtUnixMs <= 0 {
return UploadResult{}, errors.New("upload grant expiry is required")
}
now := time.Now
if c.Now != nil {
now = c.Now
}
if !now().Before(time.UnixMilli(grant.ExpiresAtUnixMs)) {
return UploadResult{}, ErrUploadGrantExpired
}
parsed, err := url.Parse(grant.TargetUrl)
if err != nil || parsed.Host == "" {
return UploadResult{}, errors.New("upload URL is invalid")
}
if parsed.Scheme != "https" && !(c.AllowInsecureHTTP && parsed.Scheme == "http") {
return UploadResult{}, errors.New("upload URL must use HTTPS")
}
if len(c.AllowedHosts) > 0 {
if _, ok := c.AllowedHosts[strings.ToLower(parsed.Host)]; !ok {
return UploadResult{}, fmt.Errorf("upload host %q is not allowed", parsed.Host)
}
}
if err := ctx.Err(); err != nil {
return UploadResult{}, err
}
file, err := os.Open(path)
if err != nil {
return UploadResult{}, err
}
stat, err := file.Stat()
if err != nil {
_ = file.Close()
return UploadResult{}, err
}
if stat.IsDir() {
_ = file.Close()
return UploadResult{}, errors.New("upload path is a directory")
}
if grant.MaxBytes > 0 && stat.Size() > grant.MaxBytes {
_ = file.Close()
return UploadResult{}, fmt.Errorf("asset exceeds grant limit: %d > %d", stat.Size(), grant.MaxBytes)
}
digest, err := digestFile(file)
closeErr := file.Close()
if err != nil {
return UploadResult{}, err
}
if closeErr != nil {
return UploadResult{}, closeErr
}
if grant.RequiredChecksumSha256 != "" && !strings.EqualFold(grant.RequiredChecksumSha256, digest) {
return UploadResult{}, errors.New("asset checksum does not match upload grant")
}
file, err = os.Open(path)
if err != nil {
return UploadResult{}, err
}
defer func() {
if closeErr := file.Close(); err == nil && closeErr != nil && !errors.Is(closeErr, os.ErrClosed) {
result = UploadResult{}
err = closeErr
}
}()
req, err := http.NewRequestWithContext(ctx, http.MethodPut, parsed.String(), file)
if err != nil {
return UploadResult{}, err
}
req.ContentLength = stat.Size()
for _, header := range grant.Headers {
if strings.EqualFold(header.Name, "host") || strings.EqualFold(header.Name, "content-length") {
return UploadResult{}, errors.New("upload grant contains a forbidden header")
}
req.Header.Set(header.Name, header.Value)
}
client := c.HTTPClient
if client == nil {
client = &http.Client{}
}
copyClient := *client
copyClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
resp, err := copyClient.Do(req)
if err != nil {
return UploadResult{}, err
}
defer resp.Body.Close()
maxResponse := c.MaxResponseBodySize
if maxResponse <= 0 {
maxResponse = 64 << 10
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponse))
return UploadResult{}, fmt.Errorf("upload returned HTTP %d", resp.StatusCode)
}
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponse))
return UploadResult{StatusCode: resp.StatusCode, SizeBytes: stat.Size(), SHA256: digest, ETag: resp.Header.Get("ETag")}, nil
}
func digestFile(file *os.File) (string, error) {
if _, err := file.Seek(0, io.SeekStart); err != nil {
return "", err
}
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
+87
View File
@@ -0,0 +1,87 @@
package agent
import (
"context"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
)
func TestUploadClientUsesGrantAndVerifiesChecksum(t *testing.T) {
body := []byte("mock recording bytes")
digest := sha256.Sum256(body)
var received []byte
var receivedHeader string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeader = r.Header.Get("x-upload-token")
received, _ = io.ReadAll(r.Body)
w.Header().Set("ETag", "etag-1")
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
assetPath := filepath.Join(t.TempDir(), "recording.bin")
if err := os.WriteFile(assetPath, body, 0o600); err != nil {
t.Fatal(err)
}
grant := &agentv1.UploadGrant{UploadId: "upload-1", TargetUrl: server.URL, ObjectKey: "recording-1", ExpiresAtUnixMs: time.Unix(101, 0).UnixMilli(), Headers: []*agentv1.Header{{Name: "x-upload-token", Value: "mock-token"}}, RequiredChecksumSha256: hex.EncodeToString(digest[:]), MaxBytes: int64(len(body))}
result, err := (UploadClient{AllowInsecureHTTP: true, Now: func() time.Time { return time.Unix(100, 0) }, AllowedHosts: map[string]struct{}{strings.TrimPrefix(server.URL, "http://"): {}}}).UploadFile(context.Background(), grant, assetPath)
if err != nil {
t.Fatal(err)
}
if result.StatusCode != http.StatusOK || result.SHA256 != grant.RequiredChecksumSha256 || result.ETag != "etag-1" || string(received) != string(body) || receivedHeader != "mock-token" {
t.Fatalf("unexpected upload result: %+v body=%q header=%q", result, received, receivedHeader)
}
if retained, statErr := os.Stat(assetPath); statErr != nil || retained.Size() != int64(len(body)) {
t.Fatalf("source asset was not retained after upload: stat=%v info=%v", statErr, retained)
}
}
func TestUploadClientFailsClosedForGrantMismatchAndHTTP(t *testing.T) {
assetPath := filepath.Join(t.TempDir(), "recording.bin")
if err := os.WriteFile(assetPath, []byte("bytes"), 0o600); err != nil {
t.Fatal(err)
}
grant := &agentv1.UploadGrant{UploadId: "upload-2", TargetUrl: "http://127.0.0.1:1/upload", ObjectKey: "recording-2", ExpiresAtUnixMs: time.Now().Add(time.Hour).UnixMilli(), RequiredChecksumSha256: strings.Repeat("a", 64), MaxBytes: 1024}
if _, err := (UploadClient{AllowInsecureHTTP: true}).UploadFile(context.Background(), grant, assetPath); err == nil {
t.Fatal("expected checksum mismatch")
}
grant.RequiredChecksumSha256 = ""
if _, err := (UploadClient{}).UploadFile(context.Background(), grant, assetPath); err == nil {
t.Fatal("expected HTTP upload URL rejection")
}
}
func TestUploadClientEnforcesSizeAndHost(t *testing.T) {
assetPath := filepath.Join(t.TempDir(), "recording.bin")
if err := os.WriteFile(assetPath, []byte("bytes"), 0o600); err != nil {
t.Fatal(err)
}
grant := &agentv1.UploadGrant{UploadId: "upload-3", TargetUrl: "https://oss.example.invalid/upload", ObjectKey: "recording-3", ExpiresAtUnixMs: time.Now().Add(time.Hour).UnixMilli(), MaxBytes: 1}
if _, err := (UploadClient{}).UploadFile(context.Background(), grant, assetPath); err == nil {
t.Fatal("expected size rejection")
}
grant.MaxBytes = 1024
if _, err := (UploadClient{AllowedHosts: map[string]struct{}{"other.example.invalid": {}}}).UploadFile(context.Background(), grant, assetPath); err == nil {
t.Fatal("expected host rejection")
}
}
func TestUploadClientRejectsExpiredGrant(t *testing.T) {
assetPath := filepath.Join(t.TempDir(), "recording.bin")
if err := os.WriteFile(assetPath, []byte("bytes"), 0o600); err != nil {
t.Fatal(err)
}
grant := &agentv1.UploadGrant{UploadId: "upload-expired", TargetUrl: "https://oss.example.invalid/upload", ObjectKey: "recording-expired", ExpiresAtUnixMs: time.Unix(100, 0).UnixMilli(), MaxBytes: 1024}
if _, err := (UploadClient{Now: func() time.Time { return time.Unix(100, 0) }}).UploadFile(context.Background(), grant, assetPath); err == nil {
t.Fatal("expected expired grant rejection")
}
}