mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 04:56:20 +02:00
* feat(daemon): make workspace sync event-driven Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): preserve trailing workspace changes Co-authored-by: multica-agent <github@multica.ai> * fix(workspace): reconcile failed creates Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
326 lines
11 KiB
Go
326 lines
11 KiB
Go
package daemon
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
)
|
|
|
|
// captureLogger returns a *slog.Logger whose output lands in buf, so tests
|
|
// can assert on the daemon's user-facing warning text without scraping
|
|
// stderr.
|
|
func captureLogger(buf *bytes.Buffer) *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
|
}
|
|
|
|
func TestClient_RenewToken_PostsToCorrectEndpoint(t *testing.T) {
|
|
var called atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called.Add(1)
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("expected POST, got %s", r.Method)
|
|
}
|
|
if r.URL.Path != "/api/tokens/current/renew" {
|
|
t.Errorf("expected /api/tokens/current/renew, got %s", r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("Authorization"); got != "Bearer mul_abc" {
|
|
t.Errorf("expected Bearer mul_abc, got %q", got)
|
|
}
|
|
// Body must be valid JSON — postJSON marshals an empty object when
|
|
// reqBody is a non-nil map[string]any{}.
|
|
var body map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Errorf("decode body: %v", err)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"expires_at": "2099-01-02T03:04:05Z",
|
|
"renewed": true,
|
|
})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
c := NewClient(srv.URL)
|
|
c.SetToken("mul_abc")
|
|
|
|
resp, err := c.RenewToken(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("RenewToken: %v", err)
|
|
}
|
|
if called.Load() != 1 {
|
|
t.Fatalf("expected 1 server call, got %d", called.Load())
|
|
}
|
|
if !resp.Renewed {
|
|
t.Fatal("expected renewed=true")
|
|
}
|
|
if resp.ExpiresAt != "2099-01-02T03:04:05Z" {
|
|
t.Fatalf("expected expires_at to round-trip, got %q", resp.ExpiresAt)
|
|
}
|
|
}
|
|
|
|
func TestTryRenewToken_LogsRenewalOnSuccess(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"expires_at": "2099-01-02T03:04:05Z",
|
|
"renewed": true,
|
|
})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
var buf bytes.Buffer
|
|
d := &Daemon{client: NewClient(srv.URL), logger: captureLogger(&buf)}
|
|
d.tryRenewToken(context.Background())
|
|
|
|
out := buf.String()
|
|
if !strings.Contains(out, "auth token renewed") {
|
|
t.Fatalf("expected 'auth token renewed' log, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, "2099-01-02T03:04:05Z") {
|
|
t.Fatalf("expected new expiry in log, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestTryRenewToken_LogsNotEligibleOnNoOp(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"expires_at": "2099-01-02T03:04:05Z",
|
|
"renewed": false,
|
|
})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
var buf bytes.Buffer
|
|
d := &Daemon{client: NewClient(srv.URL), logger: captureLogger(&buf)}
|
|
d.tryRenewToken(context.Background())
|
|
|
|
out := buf.String()
|
|
// Non-renewal must NOT emit the warning that an operator would interpret
|
|
// as "something is wrong" — it's the normal steady-state for tokens with
|
|
// plenty of life left.
|
|
if strings.Contains(out, "WARN") {
|
|
t.Fatalf("no-op renewal should not log at WARN, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestTryRenewToken_SurfacesReloginWarningOn401(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":"invalid token"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
var buf bytes.Buffer
|
|
d := &Daemon{client: NewClient(srv.URL), logger: captureLogger(&buf)}
|
|
d.tryRenewToken(context.Background())
|
|
|
|
out := buf.String()
|
|
if !strings.Contains(out, "level=WARN") {
|
|
t.Fatalf("401 must surface as WARN, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, "multica login") {
|
|
t.Fatalf("401 warning must tell the user to run 'multica login', got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestTryRenewToken_SurfacesReloginWarningOn401_WithProfile(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":"invalid token"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
var buf bytes.Buffer
|
|
d := &Daemon{
|
|
client: NewClient(srv.URL),
|
|
logger: captureLogger(&buf),
|
|
cfg: Config{Profile: "staging"},
|
|
}
|
|
d.tryRenewToken(context.Background())
|
|
|
|
out := buf.String()
|
|
if !strings.Contains(out, "--profile staging") {
|
|
t.Fatalf("profile-aware login hint missing, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestTryRenewToken_TransientErrorIsDebugNotWarn(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(`{"error":"db down"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
var buf bytes.Buffer
|
|
d := &Daemon{client: NewClient(srv.URL), logger: captureLogger(&buf)}
|
|
d.tryRenewToken(context.Background())
|
|
|
|
out := buf.String()
|
|
// A 500 is transient — the next tick will retry, so the operator should
|
|
// NOT see a re-login warning that doesn't reflect the actual cause.
|
|
if strings.Contains(out, "level=WARN") {
|
|
t.Fatalf("transient 500 should not log at WARN, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, "token renewal failed") {
|
|
t.Fatalf("expected debug log about renewal failure, got: %s", out)
|
|
}
|
|
}
|
|
|
|
// TestPreflightAuth_RenewsBeforeWorkspaceSyncOnExpiredToken locks in the
|
|
// must-fix from MUL-2744 review: when the daemon starts with an already-
|
|
// revoked or expired PAT, the renewal call has to happen BEFORE the first
|
|
// workspace sync, because the workspace sync's 401 would short-circuit Run
|
|
// and the operator would never see a "run multica login" hint.
|
|
func TestPreflightAuth_RenewsBeforeWorkspaceSyncOnExpiredToken(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var seen []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
seen = append(seen, r.URL.Path)
|
|
mu.Unlock()
|
|
// Both endpoints 401 — this is the "PAT already revoked/expired
|
|
// before the daemon even started" failure mode.
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":"invalid token"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
var buf bytes.Buffer
|
|
d := &Daemon{client: NewClient(srv.URL), logger: captureLogger(&buf)}
|
|
d.client.SetToken("mul_already_revoked")
|
|
|
|
err := d.preflightAuth(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected workspace sync to fail with 401")
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(seen) < 2 {
|
|
t.Fatalf("expected both endpoints to be called; got %v", seen)
|
|
}
|
|
if seen[0] != "/api/tokens/current/renew" {
|
|
t.Fatalf("renew must be the first API call so the WARN fires before the sync 401s; got order %v", seen)
|
|
}
|
|
if seen[1] != "/api/daemon/workspaces" {
|
|
t.Fatalf("workspace sync should follow renew; got order %v", seen)
|
|
}
|
|
out := buf.String()
|
|
if !strings.Contains(out, "level=WARN") {
|
|
t.Fatalf("expected re-login WARN, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, "multica login") {
|
|
t.Fatalf("expected the actionable 'run multica login' hint in the WARN, got: %s", out)
|
|
}
|
|
}
|
|
|
|
// TestPreflightAuth_SyncProceedsWhenRenewIsNoOp covers the steady-state
|
|
// startup: a PAT well outside the renewal window returns renewed=false,
|
|
// and preflightAuth must still go on to do the workspace sync. The
|
|
// renewal is best-effort and must not gate startup.
|
|
func TestPreflightAuth_SyncProceedsWhenRenewIsNoOp(t *testing.T) {
|
|
var syncCalled atomic.Bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/tokens/current/renew":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"expires_at": "2099-01-02T03:04:05Z",
|
|
"renewed": false,
|
|
})
|
|
case "/api/daemon/workspaces":
|
|
syncCalled.Store(true)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`[]`))
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
var buf bytes.Buffer
|
|
d := &Daemon{client: NewClient(srv.URL), logger: captureLogger(&buf)}
|
|
d.client.SetToken("mul_healthy")
|
|
|
|
if err := d.preflightAuth(context.Background()); err != nil {
|
|
t.Fatalf("preflightAuth returned error on healthy startup: %v", err)
|
|
}
|
|
if !syncCalled.Load() {
|
|
t.Fatal("preflightAuth must run the workspace sync after a no-op renewal")
|
|
}
|
|
}
|
|
|
|
// TestPreflightAuth_TransientRenewFailureDoesNotBlockStartup covers the
|
|
// "renewal endpoint is briefly down" path. The renewal failure must not
|
|
// kill the daemon — the workspace sync still happens, and the daemon is
|
|
// up and serving. The background renewal loop will retry later.
|
|
func TestPreflightAuth_TransientRenewFailureDoesNotBlockStartup(t *testing.T) {
|
|
var syncCalled atomic.Bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/tokens/current/renew":
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(`{"error":"db down"}`))
|
|
case "/api/daemon/workspaces":
|
|
syncCalled.Store(true)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`[]`))
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
var buf bytes.Buffer
|
|
d := &Daemon{client: NewClient(srv.URL), logger: captureLogger(&buf)}
|
|
d.client.SetToken("mul_healthy")
|
|
|
|
if err := d.preflightAuth(context.Background()); err != nil {
|
|
t.Fatalf("preflightAuth must not surface transient renew failures: %v", err)
|
|
}
|
|
if !syncCalled.Load() {
|
|
t.Fatal("transient renew failure must not skip the workspace sync")
|
|
}
|
|
if strings.Contains(buf.String(), "level=WARN") {
|
|
t.Fatalf("transient 500 must not emit the re-login WARN, got: %s", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestTryRenewToken_RespectsContextTimeout(t *testing.T) {
|
|
// Server that never responds — the per-call 15s timeout inside
|
|
// tryRenewToken is too long for a unit test, so cancel the parent
|
|
// context immediately and verify the call returns.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
<-r.Context().Done()
|
|
_, _ = io.Copy(io.Discard, r.Body)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
var buf bytes.Buffer
|
|
d := &Daemon{client: NewClient(srv.URL), logger: captureLogger(&buf)}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
d.tryRenewToken(ctx)
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
// Expected: tryRenewToken returns once the cancelled ctx propagates
|
|
// through the HTTP client.
|
|
case <-context.Background().Done():
|
|
}
|
|
}
|