mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-20 05:28:15 +02:00
Lot 17 : interrupteurs système tenus par la façade (LOKI_SYSTEM=rust)
La façade sert /api/internet (moteur web, serveur Crawl4AI et sa clé jamais rendue, joignabilité avec son cache de 30 s), /api/computer (interrupteur du navigateur, binaire trouvé, vision), /api/apikey (clé de complétion, repli sur config.env, adresse /v1 à coller dans un client tiers) et /api/network (écoute du moteur) quand LOKI_SYSTEM=rust (expérimental ; le Go reste le défaut). Restent au Go derrière son API interne (facade_system.go) : le cache de joignabilité de ses outils web (/internal/v1/web/reach/reset), le Chromium des outils browser_* (/internal/v1/cdp/shutdown) et le redémarrage qui applique une clé neuve quand la façade ne pilote pas le moteur (/internal/v1/service/action). Le relais du tunnel garde l'hôte et X-Forwarded-Proto du client. Correction en passant : maskAPIKey paniquait sur une clé d'un octet, et /api/apikey tombait à chaque lecture. Parité : parity_system_test.go tire du Go 61 appels de route avec la base et les sondes reçues par trois faux Crawl4AI après chacun, et huit dispositions du dossier de Playwright, rejoués par tests/system.rs ; ce qui reste au Go vérifié de bout en bout ; TestFacadeSysteme, TestFacadeRelaisGardeHote et TestMaskAPIKeyCourte côté Go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
19 files changed
+3110
-3
No files matched your search
@@ -73,11 +73,16 @@ func writeAPIKey(key string) error {
|
||||
}
|
||||
|
||||
// maskAPIKey renders a key for display: keep the "sk-loki-" prefix and last 4
|
||||
// chars, elide the middle. Empty in → empty out.
|
||||
// chars, elide the middle. Empty in → empty out. Une clé d'un ou deux octets
|
||||
// n'est pas montrée du tout : k[len(k)-2:] paniquait sur un seul octet, et
|
||||
// /api/apikey tombait à chaque lecture tant que la clé restait en place.
|
||||
func maskAPIKey(k string) string {
|
||||
if k == "" {
|
||||
return ""
|
||||
}
|
||||
if len(k) <= 2 {
|
||||
return "…"
|
||||
}
|
||||
if len(k) <= 13 {
|
||||
return "…" + k[len(k)-2:]
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ var facadeChatRoutes = map[string]bool{
|
||||
// (facade_dictate.go), les réglages partagés (facade_settings.go), les fichiers
|
||||
// de la discussion (facade_files.go), les notifications (facade_push.go), la
|
||||
// mémoire (facade_mem.go), les presets (facade_presets.go), le pilotage du
|
||||
// moteur (facade_engine.go).
|
||||
// moteur (facade_engine.go), les interrupteurs système (facade_system.go).
|
||||
// Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le
|
||||
// jeton interne qui en tient lieu là-bas.
|
||||
func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
|
||||
@@ -168,6 +168,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
|
||||
held, unavailable = facadePresets, "presets indisponibles : "
|
||||
case facadeEngineRoutes[path]:
|
||||
held, unavailable = facadeEngine, "pilotage du moteur indisponible : "
|
||||
case facadeSystemRoutes[path]:
|
||||
held, unavailable = facadeSystem, "réglages système indisponibles : "
|
||||
default:
|
||||
return h
|
||||
}
|
||||
@@ -186,6 +188,12 @@ func facadeRelayAs(w http.ResponseWriter, r *http.Request, unavailable string) {
|
||||
rp := &httputil.ReverseProxy{
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.SetURL(&url.URL{Scheme: "http", Host: facadeAddr()})
|
||||
// L'hôte et le schéma du client, pas ceux du relais : la clé de
|
||||
// complétion affiche l'adresse à coller dans un client tiers.
|
||||
pr.Out.Host = pr.In.Host
|
||||
if p := pr.In.Header.Get("X-Forwarded-Proto"); p != "" {
|
||||
pr.Out.Header.Set("X-Forwarded-Proto", p)
|
||||
}
|
||||
pr.Out.Header.Set(internalTokenHeader, internalToken())
|
||||
},
|
||||
FlushInterval: -1, // SSE : chaque trame part aussitôt
|
||||
|
||||
@@ -645,3 +645,116 @@ func TestFacadeMoteur(t *testing.T) {
|
||||
t.Fatalf("arrêt de la dictée : %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Une clé de complétion d'un ou deux octets ne fait plus tomber /api/apikey.
|
||||
func TestMaskAPIKeyCourte(t *testing.T) {
|
||||
for k, want := range map[string]string{
|
||||
"": "",
|
||||
"k": "…",
|
||||
"ab": "…",
|
||||
"abc": "…bc",
|
||||
"abcdefghijklm": "…lm",
|
||||
"sk-loki-0123456789abcdef": "sk-loki-0…cdef",
|
||||
} {
|
||||
if got := maskAPIKey(k); got != want {
|
||||
t.Errorf("maskAPIKey(%q) = %q, attendu %q", k, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Les interrupteurs système (LOKI_SYSTEM=rust, lot 17) partent à la façade ;
|
||||
// l'API interne lui rend ce qui vit ici : cache de joignabilité des outils
|
||||
// web, navigateur piloté, redémarrage du moteur.
|
||||
func TestFacadeSysteme(t *testing.T) {
|
||||
testHome(t)
|
||||
t.Setenv("LOKI_CONTAINER", "1")
|
||||
f := demarrerFacade(t)
|
||||
t.Setenv(facadeChatEnv, "")
|
||||
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
||||
for p := range facadeSystemRoutes {
|
||||
w := httptest.NewRecorder()
|
||||
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
|
||||
if w.Body.String() != "go" {
|
||||
t.Fatalf("%s servi ailleurs sans LOKI_SYSTEM : %q", p, w.Body.String())
|
||||
}
|
||||
}
|
||||
t.Setenv(facadeSystemEnv, "rust")
|
||||
for p := range facadeSystemRoutes {
|
||||
w := httptest.NewRecorder()
|
||||
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
|
||||
if w.Body.String() != f.reply {
|
||||
t.Fatalf("%s : réponse %q", p, w.Body.String())
|
||||
}
|
||||
}
|
||||
if n := len(f.recu()); n != len(facadeSystemRoutes) {
|
||||
t.Fatalf("reçu par la façade : %d appels", n)
|
||||
}
|
||||
|
||||
// Cache de joignabilité vidé.
|
||||
reachMu.Lock()
|
||||
reachURL = "http://ancien"
|
||||
reachMu.Unlock()
|
||||
w := httptest.NewRecorder()
|
||||
handleInternalReachReset(w, httptest.NewRequest("POST", "/internal/v1/web/reach/reset", nil))
|
||||
reachMu.Lock()
|
||||
vide := reachURL == ""
|
||||
reachMu.Unlock()
|
||||
if w.Code != 204 || !vide {
|
||||
t.Fatalf("reach/reset : %d, cache vidé : %v", w.Code, vide)
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
handleInternalCDPShutdown(w, httptest.NewRequest("POST", "/internal/v1/cdp/shutdown", nil))
|
||||
if w.Code != 204 {
|
||||
t.Fatalf("cdp/shutdown : %d", w.Code)
|
||||
}
|
||||
|
||||
// Redémarrage : seulement si le moteur tourne quand if_active est posé.
|
||||
var actions []string
|
||||
old := internalServiceAction
|
||||
t.Cleanup(func() { internalServiceAction = old })
|
||||
internalServiceAction = func(a string) error {
|
||||
actions = append(actions, a)
|
||||
if a == "start" {
|
||||
return errTuneBusy
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, c := range []struct {
|
||||
body, want string
|
||||
code int
|
||||
}{
|
||||
{`{"action":"restart","if_active":true}`, `{"error":"","ran":false}`, 200},
|
||||
{`{"action":"restart"}`, `{"error":"","ran":true}`, 200},
|
||||
{`{"action":"start"}`, `{"error":"` + errTuneBusy.Error() + `","ran":true}`, 200},
|
||||
{`{"action":"reload"}`, `{"error":"action inconnue : reload"}`, 400},
|
||||
{`{"action":`, `{"error":"unexpected EOF"}`, 400},
|
||||
} {
|
||||
w := httptest.NewRecorder()
|
||||
handleInternalServiceAction(w, httptest.NewRequest("POST", "/internal/v1/service/action", strings.NewReader(c.body)))
|
||||
if got := strings.TrimSpace(w.Body.String()); w.Code != c.code || got != c.want {
|
||||
t.Fatalf("%s : %d %s", c.body, w.Code, got)
|
||||
}
|
||||
}
|
||||
if strings.Join(actions, ",") != "restart,start" {
|
||||
t.Fatalf("actions : %v", actions)
|
||||
}
|
||||
}
|
||||
|
||||
// Le relais vers la façade garde l'hôte et le schéma du client : la clé de
|
||||
// complétion y calcule l'adresse à coller dans un client tiers.
|
||||
func TestFacadeRelaisGardeHote(t *testing.T) {
|
||||
var host, proto string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host, proto = r.Host, r.Header.Get("X-Forwarded-Proto")
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv(internalTokenEnv, jetonTest)
|
||||
t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://"))
|
||||
r := httptest.NewRequest("GET", "/api/apikey", nil)
|
||||
r.Host = "loki.exemple.fr"
|
||||
r.Header.Set("X-Forwarded-Proto", "https")
|
||||
facadeRelayAs(httptest.NewRecorder(), r, "")
|
||||
if host != "loki.exemple.fr" || proto != "https" {
|
||||
t.Fatalf("hôte %q, schéma %q", host, proto)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// facade_system.go — interrupteurs système tenus par la façade Rust
|
||||
// (LOKI_SYSTEM=rust, lot 17 de la migration ; expérimental, le Go reste le
|
||||
// défaut) : accès internet (/api/internet), pilotage du navigateur
|
||||
// (/api/computer), clé de complétion (/api/apikey) et écoute réseau du moteur
|
||||
// (/api/network).
|
||||
//
|
||||
// Les réglages restent des clés de la base. Ce qui vit dans ce processus reste
|
||||
// ici, derrière l'API interne :
|
||||
//
|
||||
// - le cache de joignabilité de Crawl4AI des outils web, vidé quand l'URL, la
|
||||
// clé ou le moteur changent (/internal/v1/web/reach/reset) ;
|
||||
// - le Chromium piloté par les outils browser_*, fermé quand l'interrupteur
|
||||
// se coupe (/internal/v1/cdp/shutdown) ;
|
||||
// - le redémarrage du moteur qui applique une nouvelle clé, quand la façade
|
||||
// ne pilote pas le moteur (/internal/v1/service/action).
|
||||
|
||||
const facadeSystemEnv = "LOKI_SYSTEM"
|
||||
|
||||
// facadeSystem : la façade tient-elle les interrupteurs système ?
|
||||
func facadeSystem() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeSystemEnv)), "rust") &&
|
||||
facadeAddr() != "" && internalToken() != ""
|
||||
}
|
||||
|
||||
// facadeSystemRoutes : les routes que la façade sert alors (system.rs).
|
||||
var facadeSystemRoutes = map[string]bool{
|
||||
"/api/internet": true,
|
||||
"/api/computer": true,
|
||||
"/api/apikey": true,
|
||||
"/api/network": true,
|
||||
}
|
||||
|
||||
// handleInternalReachReset : POST → 204 — le prochain tour reteste Crawl4AI.
|
||||
func handleInternalReachReset(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"})
|
||||
return
|
||||
}
|
||||
reachMu.Lock()
|
||||
reachURL = ""
|
||||
reachMu.Unlock()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleInternalCDPShutdown : POST → 204 — ferme le navigateur piloté.
|
||||
func handleInternalCDPShutdown(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"})
|
||||
return
|
||||
}
|
||||
cdpShutdown()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// internalServiceAction : serviceAction, variable pour les tests.
|
||||
var internalServiceAction = serviceAction
|
||||
|
||||
// handleInternalServiceAction : POST {action, if_active} → 200 {ran, error}.
|
||||
// if_active : seulement si le moteur tourne (une clé neuve ne s'applique qu'au
|
||||
// redémarrage, inutile de démarrer un moteur arrêté pour elle).
|
||||
func handleInternalServiceAction(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Action string `json:"action"`
|
||||
IfActive bool `json:"if_active"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
sendJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
switch in.Action {
|
||||
case "start", "stop", "restart":
|
||||
default:
|
||||
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "action inconnue : " + in.Action})
|
||||
return
|
||||
}
|
||||
if in.IfActive && !serviceIsActive() {
|
||||
sendJSON(w, http.StatusOK, map[string]any{"ran": false, "error": ""})
|
||||
return
|
||||
}
|
||||
msg := ""
|
||||
if err := internalServiceAction(in.Action); err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
sendJSON(w, http.StatusOK, map[string]any{"ran": true, "error": msg})
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// parity_system_test.go — journal de référence des interrupteurs système
|
||||
// (lot 17 de la migration Rust) : accès internet et Crawl4AI (moteur, URL,
|
||||
// clé jamais rendue, joignabilité et son cache), pilotage du navigateur et
|
||||
// vision, clé de complétion (génération, saisie, effacement, repli sur
|
||||
// config.env, adresse à coller dans un client tiers, refus pendant une
|
||||
// optimisation) et écoute réseau du moteur. Après chaque appel : les clés
|
||||
// touchées en base et les en-têtes reçus par les faux serveurs Crawl4AI.
|
||||
// Rangé dans rust/loki-rs/tests/parity_system/system.json ; tests/system.rs
|
||||
// le rejoue par la façade.
|
||||
//
|
||||
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteSysteme
|
||||
|
||||
const paritySystemFile = "../../rust/loki-rs/tests/parity_system/system.json"
|
||||
|
||||
type paritySystemStep struct {
|
||||
Name string `json:"name"`
|
||||
Config map[string]string `json:"config,omitempty"` // posé avant l'appel ; "" = supprimé
|
||||
State map[string]string `json:"state,omitempty"`
|
||||
Tune bool `json:"tune,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Status int `json:"status"`
|
||||
Out any `json:"out"`
|
||||
After map[string]string `json:"after"`
|
||||
Probes []string `json:"probes"` // « chemin Authorization » reçus depuis l'appel précédent
|
||||
}
|
||||
|
||||
// paritySystemKeys : ce que les routes touchent, relu après chaque appel.
|
||||
var paritySystemKeys = []string{
|
||||
"config/WEB_ENGINE", "config/CRAWL4AI_URL", "config/CRAWL4AI_KEY", "config/API_KEY", "config/HOST",
|
||||
"state/internet", "state/computer", "state/crawl_key", "state/api_key", "state/public_url",
|
||||
}
|
||||
|
||||
var (
|
||||
parityKeyRe = regexp.MustCompile(`sk-loki-[0-9a-f]{48}`)
|
||||
parityMaskedRe = regexp.MustCompile(`sk-loki-[0-9a-f]…[0-9a-f]{4}`)
|
||||
)
|
||||
|
||||
func TestPariteSysteme(t *testing.T) {
|
||||
home := testHome(t)
|
||||
t.Setenv("LOKI_CONTAINER", "1")
|
||||
t.Setenv("LOKI_CHROME", "/opt/faux/chrome")
|
||||
|
||||
var mu sync.Mutex
|
||||
var probes []string
|
||||
fake := func(code int) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
probes = append(probes, strings.TrimSpace(r.URL.Path+" "+r.Header.Get("Authorization")))
|
||||
mu.Unlock()
|
||||
w.WriteHeader(code)
|
||||
}))
|
||||
}
|
||||
ok, down, notFound := fake(200), fake(500), fake(404)
|
||||
defer ok.Close()
|
||||
defer down.Close()
|
||||
defer notFound.Close()
|
||||
|
||||
mask := func(s string) string {
|
||||
s = strings.ReplaceAll(s, ok.URL, "{{OK}}")
|
||||
s = strings.ReplaceAll(s, down.URL, "{{DOWN}}")
|
||||
s = strings.ReplaceAll(s, notFound.URL, "{{NOTFOUND}}")
|
||||
s = strings.ReplaceAll(s, home, "{{HOME}}")
|
||||
if ip := localIP(); ip != "" {
|
||||
s = strings.ReplaceAll(s, "http://"+ip+":", "http://{{IP}}:")
|
||||
}
|
||||
s = parityKeyRe.ReplaceAllString(s, "{{KEY}}")
|
||||
return parityMaskedRe.ReplaceAllString(s, "{{MASKED}}")
|
||||
}
|
||||
unmask := func(s string) string {
|
||||
s = strings.ReplaceAll(s, "{{OK}}", ok.URL)
|
||||
s = strings.ReplaceAll(s, "{{DOWN}}", down.URL)
|
||||
return strings.ReplaceAll(s, "{{NOTFOUND}}", notFound.URL)
|
||||
}
|
||||
handlers := map[string]http.HandlerFunc{
|
||||
"/api/internet": handleInternet,
|
||||
"/api/computer": handleComputer,
|
||||
"/api/apikey": handleAPIKey,
|
||||
"/api/network": handleNetwork,
|
||||
}
|
||||
run := func(st paritySystemStep) paritySystemStep {
|
||||
for k, v := range st.Config {
|
||||
if err := SetConfigKey(k, unmask(v)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for k, v := range st.State {
|
||||
if err := putStr(bkState, k, unmask(v)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var lock *tuneLock
|
||||
if st.Tune {
|
||||
l, err := tuneLockAcquire("web", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lock = l
|
||||
}
|
||||
mu.Lock()
|
||||
probes = nil
|
||||
mu.Unlock()
|
||||
req := httptest.NewRequest(st.Method, st.Path, strings.NewReader(unmask(st.Body)))
|
||||
req.Host = "loki.local:8090"
|
||||
for k, v := range st.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handlers[st.Path](rec, req)
|
||||
if lock != nil {
|
||||
lock.release()
|
||||
}
|
||||
st.Status = rec.Code
|
||||
var out any
|
||||
if err := json.Unmarshal([]byte(mask(rec.Body.String())), &out); err != nil {
|
||||
t.Fatalf("%s : corps illisible %q", st.Name, rec.Body.String())
|
||||
}
|
||||
st.Out = out
|
||||
st.After = map[string]string{}
|
||||
for _, k := range paritySystemKeys {
|
||||
b, key, _ := strings.Cut(k, "/")
|
||||
if v := getStr(b, key); v != "" {
|
||||
st.After[k] = mask(v)
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
st.Probes = append([]string{}, probes...)
|
||||
mu.Unlock()
|
||||
for i := range st.Probes {
|
||||
st.Probes[i] = mask(st.Probes[i])
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
var steps []paritySystemStep
|
||||
for _, st := range []paritySystemStep{
|
||||
// --- accès internet ---
|
||||
{Name: "internet par défaut", Method: "GET", Path: "/api/internet"},
|
||||
{Name: "allumé, moteur intégré", Method: "POST", Path: "/api/internet", Body: `{"enabled":true}`},
|
||||
{Name: "moteur crawl4ai sans URL", Method: "POST", Path: "/api/internet", Body: `{"engine":" CRAWL4AI "}`},
|
||||
{Name: "moteur inconnu", Method: "POST", Path: "/api/internet", Body: `{"engine":"bing"}`},
|
||||
{Name: "moteur inconnu vide", Method: "POST", Path: "/api/internet", Body: `{"engine":" "}`},
|
||||
{Name: "URL joignable", Method: "POST", Path: "/api/internet", Body: `{"url":" {{OK}}/// "}`},
|
||||
{Name: "relu dans le cache", Method: "GET", Path: "/api/internet"},
|
||||
{Name: "clé posée", Method: "POST", Path: "/api/internet", Body: `{"key":" secret-clé-crawl "}`},
|
||||
{Name: "clé courte", Method: "POST", Path: "/api/internet", Body: `{"key":"abcd"}`},
|
||||
{Name: "clé d'un caractère", Method: "POST", Path: "/api/internet", Body: `{"key":"é"}`},
|
||||
{Name: "clé résiduelle en config", Config: map[string]string{"CRAWL4AI_KEY": "vieille"},
|
||||
Method: "POST", Path: "/api/internet", Body: `{"key":"neuve12345"}`},
|
||||
{Name: "URL en panne", Method: "POST", Path: "/api/internet", Body: `{"url":"{{DOWN}}"}`},
|
||||
{Name: "URL 404", Method: "POST", Path: "/api/internet", Body: `{"url":"{{NOTFOUND}}"}`},
|
||||
{Name: "URL injoignable", Method: "POST", Path: "/api/internet", Body: `{"url":"http://127.0.0.1:1"}`},
|
||||
{Name: "URL sans schéma", Method: "POST", Path: "/api/internet", Body: `{"url":"exemple"}`},
|
||||
{Name: "type faux, le reste passe", Method: "POST", Path: "/api/internet", Body: `{"enabled":"oui","url":"{{OK}}"}`},
|
||||
{Name: "corps illisible", Method: "POST", Path: "/api/internet", Body: `{"enabled":false`},
|
||||
{Name: "éteint, clé effacée", Method: "POST", Path: "/api/internet", Body: `{"enabled":false,"key":""}`},
|
||||
{Name: "null ne touche à rien", Method: "POST", Path: "/api/internet", Body: `{"enabled":null,"url":null,"engine":null}`},
|
||||
{Name: "URL vidée, moteur choisi gardé", Method: "POST", Path: "/api/internet", Body: `{"url":"","enabled":true}`},
|
||||
{Name: "moteur go", Method: "POST", Path: "/api/internet", Body: `{"engine":"Go"}`},
|
||||
{Name: "moteur non choisi, URL héritée", Config: map[string]string{"WEB_ENGINE": "", "CRAWL4AI_URL": "{{OK}}"},
|
||||
Method: "GET", Path: "/api/internet"},
|
||||
{Name: "moteur illisible en config", Config: map[string]string{"WEB_ENGINE": "autre", "CRAWL4AI_URL": ""},
|
||||
Method: "GET", Path: "/api/internet"},
|
||||
{Name: "URL en config avec slashs", Config: map[string]string{"WEB_ENGINE": "crawl4ai", "CRAWL4AI_URL": " {{DOWN}}// "},
|
||||
Method: "GET", Path: "/api/internet"},
|
||||
{Name: "internet en PUT", Method: "PUT", Path: "/api/internet", Body: `{"enabled":true}`},
|
||||
|
||||
// --- navigateur ---
|
||||
{Name: "navigateur par défaut", Method: "GET", Path: "/api/computer"},
|
||||
{Name: "navigateur allumé", Method: "POST", Path: "/api/computer", Body: `{"on":true}`},
|
||||
{Name: "navigateur, corps illisible", Method: "POST", Path: "/api/computer", Body: `oui`},
|
||||
{Name: "vision par projecteur", Config: map[string]string{"MMPROJ": " mmproj.gguf "},
|
||||
Method: "GET", Path: "/api/computer"},
|
||||
{Name: "vision externe", Config: map[string]string{"MMPROJ": "", "EXTERNAL": "1", "EXTERNAL_VISION": "1"},
|
||||
Method: "GET", Path: "/api/computer"},
|
||||
{Name: "externe sans vision", Config: map[string]string{"EXTERNAL_VISION": "0", "MMPROJ": "m.gguf"},
|
||||
Method: "GET", Path: "/api/computer"},
|
||||
{Name: "vision strata", Config: map[string]string{"EXTERNAL": "", "ENGINE": "strata"},
|
||||
Method: "GET", Path: "/api/computer"},
|
||||
{Name: "strata sans vision", Config: map[string]string{"STRATA_VISION": "0", "MMPROJ": ""},
|
||||
Method: "GET", Path: "/api/computer"},
|
||||
{Name: "navigateur éteint", Config: map[string]string{"ENGINE": "", "STRATA_VISION": ""},
|
||||
Method: "POST", Path: "/api/computer", Body: `{"on":false}`},
|
||||
|
||||
// --- clé de complétion ---
|
||||
{Name: "clé par défaut", Method: "GET", Path: "/api/apikey"},
|
||||
{Name: "clé générée", Method: "POST", Path: "/api/apikey", Body: `{"action":"generate"}`},
|
||||
{Name: "clé relue", Method: "GET", Path: "/api/apikey", Headers: map[string]string{"X-Forwarded-Proto": "HTTPS, http"}},
|
||||
{Name: "clé saisie", Method: "POST", Path: "/api/apikey", Body: `{"action":"set","key":" ma-clé-perso-é "}`},
|
||||
{Name: "clé saisie courte", Method: "POST", Path: "/api/apikey", Body: `{"action":"set","key":"abc"}`},
|
||||
{Name: "clé de 13 octets", Method: "POST", Path: "/api/apikey", Body: `{"action":"set","key":"abcdefghijklm"}`},
|
||||
{Name: "clé effacée", Method: "POST", Path: "/api/apikey", Body: `{"action":"clear"}`},
|
||||
{Name: "repli sur config.env", Config: map[string]string{"API_KEY": " résiduelle-config "},
|
||||
Method: "GET", Path: "/api/apikey", Headers: map[string]string{"X-Forwarded-Proto": "ftp"}},
|
||||
{Name: "adresse publique", State: map[string]string{"public_url": "https://loki.exemple.fr"},
|
||||
Method: "POST", Path: "/api/apikey", Body: `{"action":"set","key":"k"}`},
|
||||
{Name: "action inconnue", Method: "POST", Path: "/api/apikey", Body: `{"action":"rotate"}`},
|
||||
{Name: "action vide", Method: "POST", Path: "/api/apikey", Body: `{}`},
|
||||
{Name: "corps vide", Method: "POST", Path: "/api/apikey", Body: ``},
|
||||
{Name: "corps illisible", Method: "POST", Path: "/api/apikey", Body: `{"action":`},
|
||||
{Name: "type faux", Method: "POST", Path: "/api/apikey", Body: `{"action":1}`},
|
||||
{Name: "optimisation en cours", Tune: true, Method: "POST", Path: "/api/apikey", Body: `{"action":"clear"}`},
|
||||
{Name: "lecture pendant une optimisation", Tune: true, Method: "GET", Path: "/api/apikey"},
|
||||
|
||||
// --- réseau ---
|
||||
{Name: "réseau par défaut", Method: "GET", Path: "/api/network"},
|
||||
{Name: "réseau fermé", Method: "POST", Path: "/api/network", Body: `{"exposed":false}`},
|
||||
{Name: "réseau ouvert", Method: "POST", Path: "/api/network", Body: `{"exposed":true}`},
|
||||
{Name: "réseau sans champ", Method: "POST", Path: "/api/network", Body: `{}`},
|
||||
{Name: "réseau illisible", Method: "POST", Path: "/api/network", Body: `{"exposed":1}`},
|
||||
{Name: "réseau corps vide", Method: "POST", Path: "/api/network", Body: ``},
|
||||
{Name: "HOST localhost", Config: map[string]string{"HOST": " LocalHost ", "PORT": "9090"}, Method: "GET", Path: "/api/network"},
|
||||
{Name: "HOST ::1", Config: map[string]string{"HOST": "::1", "PORT": "abc"}, Method: "GET", Path: "/api/network"},
|
||||
{Name: "HOST d'une carte", Config: map[string]string{"HOST": "192.168.1.5", "PORT": ""}, Method: "GET", Path: "/api/network"},
|
||||
} {
|
||||
steps = append(steps, run(st))
|
||||
}
|
||||
|
||||
// --- le Chromium de Playwright, le plus récent ---
|
||||
type chromiumCase struct {
|
||||
Files []string `json:"files"` // relatifs à la racine ; « / » final = dossier
|
||||
Out string `json:"out"`
|
||||
}
|
||||
var chromium []chromiumCase
|
||||
for _, files := range [][]string{
|
||||
{"chromium-1000/chrome-linux/chrome", "chromium-1200/chrome-linux/chrome"},
|
||||
{"chromium-1000/chrome-linux/chrome", "chromium-1200/chrome-linux/headless_shell"},
|
||||
{"chromium-1200/chrome-linux/headless_shell", "chromium-1200/chrome-linux/chrome"},
|
||||
{"chromium-1000/chrome-linux/chrome", "chromium_headless_shell-1100/chrome-linux/headless_shell"},
|
||||
{"chromium-1000/chrome-linux/chrome", "chromium-1300/chrome-linux/chrome/"},
|
||||
{"chromiumX/chrome-linux/chrome", "chromium-1/chrome-linux/chrome"},
|
||||
{"firefox-1/chrome-linux/chrome", "chromium-/chrome-linux/chrome"},
|
||||
{"chromium-9/chrome-linux/chrome", "chromium-10/chrome-linux/chrome"},
|
||||
} {
|
||||
root := t.TempDir()
|
||||
for _, f := range files {
|
||||
p := filepath.Join(root, f)
|
||||
if strings.HasSuffix(f, "/") {
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, nil, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("PLAYWRIGHT_BROWSERS_PATH", root)
|
||||
chromium = append(chromium, chromiumCase{Files: files,
|
||||
Out: strings.TrimPrefix(playwrightChromium(), root+"/")})
|
||||
}
|
||||
|
||||
got, err := parityJSON(map[string]any{"steps": steps, "chromium": chromium})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
|
||||
if err := os.MkdirAll(filepath.Dir(paritySystemFile), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(paritySystemFile, got, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(paritySystemFile)
|
||||
if err != nil {
|
||||
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Errorf("les interrupteurs système ne produisent plus le journal rangé dans %s.\n"+
|
||||
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteSysteme,\n"+
|
||||
"puis cargo test --test system dit si le port Rust suit.\n--- obtenu ---\n%s", paritySystemFile, got)
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,12 @@ func withInternalAPI(next http.Handler) http.Handler {
|
||||
handleInternalChatGenerating(w, r)
|
||||
case "/internal/v1/dictate/stop":
|
||||
handleInternalDictateStop(w, r)
|
||||
case "/internal/v1/web/reach/reset":
|
||||
handleInternalReachReset(w, r)
|
||||
case "/internal/v1/cdp/shutdown":
|
||||
handleInternalCDPShutdown(w, r)
|
||||
case "/internal/v1/service/action":
|
||||
handleInternalServiceAction(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user