Files
Felis/internal/updater/runner_test.go
T
flyemoji 7d27640c07 feat(updater): add GitHub Releases source and route felis-api/k3s/cloudflared
Give RoutingSource its second upstream so every non-pinned component now
resolves a real latest-stable: Velocity via PaperMC (already wired), and
felis-api, k3s and cloudflared via the GitHub REST API.

github.go queries /repos/{repo}/releases/latest (one request, rate-limit
friendly) and fails closed: a transport error, a non-200 status (404 = no
stable release), an undecodable body, a draft/prerelease flag, or an
unparseable / prerelease-parsing tag all return an error, never a zero
version. It sends the User-Agent GitHub requires (a UA-less request is
403'd) and tolerates the two live tag styles -- cloudflared's CalVer
"2026.6.1" and k3s's v-prefixed, build-tagged "v1.36.2+k3s1" -- while
String() keeps the raw tag for the report.

source.go routes sourceGitHub to it and drops the errGitHubNotWired stub;
velocity still routes to PaperMC.

Tests: github_test.go covers both tag styles, the User-Agent gate, and
fail-closed on 404 / prerelease-flag / unparseable tag, with fixtures
captured from api.github.com on 2026-07-05. runner_test.go now drives
PaperMC and GitHub through dual httptest servers end to end with no source
degrading to an error.

doc.go re-tiers the verification boundary: both release sources are now
built and live-grounded; the VersionGatherer's version-extraction core is
the next verifiable slice (logic over an exec seam, not pure I/O); the
genuine I/O remainder is the Notifier, Applier and felis update CLI/CronJob.
felis-api's coord is still a placeholder slug, so that component is dark at
runtime until a real repository is configured.
2026-07-05 01:03:32 +09:00

197 lines
6.9 KiB
Go

package updater
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/updates"
)
func mustV(t *testing.T, s string) updates.Version {
t.Helper()
v, err := updates.Parse(s)
if err != nil {
t.Fatalf("parse %q: %v", s, err)
}
return v
}
// fakeGatherer returns canned current versions, or an error for names in failFor.
type fakeGatherer struct {
cur map[string]updates.Version
failFor map[string]bool
}
func (f fakeGatherer) Current(_ context.Context, s Spec) (updates.Version, error) {
if f.failFor[s.Name] {
return updates.Version{}, errors.New("gather boom")
}
v, ok := f.cur[s.Name]
if !ok {
return updates.Version{}, errors.New("no current for " + s.Name)
}
return v, nil
}
// fakeSource returns canned latest versions, isolating the runner's wiring from any
// real release source.
type fakeSource struct{ latest map[string]updates.Version }
func (f fakeSource) Latest(_ context.Context, c updates.Component) (updates.Version, error) {
v, ok := f.latest[c.Name]
if !ok {
return updates.Version{}, errors.New("not found")
}
return v, nil
}
// TestRunnerReportOnlyComposesPlan drives the whole slice with fakes and nil
// notifier/applier (report-only): gather → assemble → updates.Run → Report. With the
// window open, the two Scheduled+manageable components (felis-api, cloudflared) plan
// to Apply, but with no applier wired the runner applies nothing and records the
// no-applier error — the honest report-only state, not a silent success.
func TestRunnerReportOnlyComposesPlan(t *testing.T) {
now := time.Date(2026, 7, 4, 3, 30, 0, 0, time.UTC)
window := updates.Window{
Start: time.Date(2026, 7, 4, 3, 0, 0, 0, time.UTC),
End: time.Date(2026, 7, 4, 4, 0, 0, 0, time.UTC),
}
gath := fakeGatherer{cur: map[string]updates.Version{
"felis-api": mustV(t, "1.4.0"),
"k3s": mustV(t, "v1.30.2+k3s1"),
"cloudflared": mustV(t, "2024.2.1"),
"velocity": mustV(t, "3.1.1"),
}}
src := fakeSource{latest: map[string]updates.Version{
"felis-api": mustV(t, "1.5.0"), // newer stable → apply (scheduled+manageable, window open)
"k3s": mustV(t, "v1.30.3+k3s1"), // newer → notify (high blast radius, unmanageable)
"cloudflared": mustV(t, "2024.3.0"), // newer stable → apply
"velocity": mustV(t, "3.4.0"), // newer stable → notify (off-cluster, unmanageable)
}}
rn := &Runner{Gatherer: gath, Source: src} // nil Notifier + nil Applier = report-only
res, err := rn.Run(context.Background(), now, window)
if err != nil {
t.Fatalf("Run: %v", err)
}
for _, want := range []string{
"felis-api", "1.5.0", "apply (scheduled window)",
"cloudflared", "2024.3.0",
"k3s", "update available (notify)",
"velocity", "3.4.0",
} {
if !strings.Contains(res.Report, want) {
t.Errorf("report missing %q; got:\n%s", want, res.Report)
}
}
if len(res.RunResult.Applied) != 0 {
t.Errorf("report-only run applied %+v, want nothing", res.RunResult.Applied)
}
for _, name := range []string{"felis-api", "cloudflared"} {
if res.RunResult.ApplyErrors[name] == nil {
t.Errorf("report-only run should record a no-applier error for %s (apply wanted, none wired)", name)
}
}
}
// TestRunnerSkipsUngatherableComponent proves a component whose current version cannot
// be read is dropped from the plan and recorded, not planned against a zero version.
func TestRunnerSkipsUngatherableComponent(t *testing.T) {
now := time.Date(2026, 7, 4, 3, 30, 0, 0, time.UTC)
gath := fakeGatherer{
cur: map[string]updates.Version{"velocity": mustV(t, "3.1.1")},
failFor: map[string]bool{"felis-api": true, "k3s": true, "cloudflared": true},
}
src := fakeSource{latest: map[string]updates.Version{"velocity": mustV(t, "3.4.0")}}
rn := &Runner{Gatherer: gath, Source: src}
res, err := rn.Run(context.Background(), now, updates.Window{})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.GatherErrors["felis-api"] == nil {
t.Error("felis-api gather failure should be recorded")
}
if len(res.RunResult.Plan) != 1 || res.RunResult.Plan[0].Component != "velocity" {
t.Errorf("plan = %+v, want just velocity (the only gatherable component)", res.RunResult.Plan)
}
}
// TestRunnerWithRoutingSource wires the real RoutingSource — PaperMC and GitHub both
// pointed at live-shape httptest fixtures — through the runner end to end. Every
// component now discovers its real latest stable (velocity 3.4.0 via PaperMC; felis-api,
// k3s and cloudflared via GitHub Releases), the raw upstream tags survive into the report
// (k3s's "+k3s1"), and NO component falls back to a recorded source error.
func TestRunnerWithRoutingSource(t *testing.T) {
now := time.Date(2026, 7, 5, 3, 30, 0, 0, time.UTC)
paperSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/v3/projects/") {
http.Error(w, "unexpected "+r.URL.Path, http.StatusNotFound)
return
}
_, _ = w.Write([]byte(velocityV3Fixture))
}))
defer paperSrv.Close()
// GitHub fixtures keyed by repo (felis-api's coord is a placeholder slug). The
// handler also mirrors GitHub's real gate: a UA-less request is refused.
ghBodies := map[string]string{
"/repos/felis/felis/releases/latest": `{"tag_name":"1.5.0","prerelease":false,"draft":false}`,
"/repos/k3s-io/k3s/releases/latest": k3sLatestFixture,
"/repos/cloudflare/cloudflared/releases/latest": cloudflaredLatestFixture,
}
ghSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("User-Agent") == "" {
http.Error(w, "Request forbidden by administrative rules", http.StatusForbidden)
return
}
body, ok := ghBodies[r.URL.Path]
if !ok {
http.Error(w, "unexpected "+r.URL.Path, http.StatusNotFound)
return
}
_, _ = w.Write([]byte(body))
}))
defer ghSrv.Close()
rs := NewRoutingSource(Topology())
rs.paper = newTestPaperMC(paperSrv) // point PaperMC discovery at its httptest server
rs.gh = newTestGitHub(ghSrv) // and GitHub discovery at its own
gath := fakeGatherer{cur: map[string]updates.Version{
"felis-api": mustV(t, "1.4.0"),
"k3s": mustV(t, "v1.35.6+k3s1"),
"cloudflared": mustV(t, "2026.5.0"),
"velocity": mustV(t, "3.1.1"),
}}
rn := &Runner{Gatherer: gath, Source: rs}
res, err := rn.Run(context.Background(), now, updates.Window{})
if err != nil {
t.Fatalf("Run: %v", err)
}
// Every component's real discovered latest is in the report (each current is older).
for _, want := range []string{
"velocity", "3.4.0",
"felis-api", "1.5.0",
"k3s", "v1.36.2+k3s1",
"cloudflared", "2026.6.1",
} {
if !strings.Contains(res.Report, want) {
t.Errorf("report missing discovered %q; got:\n%s", want, res.Report)
}
}
// Both routes are wired now, so nothing degrades to a source error.
if len(res.RunResult.SourceErrors) != 0 {
t.Errorf("expected no source errors with both routes wired, got %v", res.RunResult.SourceErrors)
}
}