GET /api/v1/logs?query=<LogsQL>&since=<cursor|time|duration>&limit=<n> returns the logs after a cursor, oldest first, with next_cursor and has_more. The cursor is a position on _time (the reception time) plus the number of rows already returned among those sharing that exact time, ordered by a hash of their content, so following the cursors gives every row once even within groups cut by a page. Rows count once older than the lag (30 s), and nothing is returned while the disk spool still holds logs. API tokens (Settings > API) give read-only access to /api/v1/ as "Authorization: Bearer lst_...", in every auth mode; only their SHA-256 is kept in /data/api_tokens.json. README (EN/FR) documents the route and an n8n workflow keeping the cursor in $getWorkflowStaticData. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
177 lines
6.0 KiB
Go
177 lines
6.0 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestTokenStore(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "api_tokens.json")
|
|
s, err := LoadTokenStore(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := s.Create(" "); err == nil {
|
|
t.Fatal("a token without a name should be refused")
|
|
}
|
|
info, secret, err := s.Create("n8n")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(secret, tokenPrefix) || len(secret) < 40 || !strings.HasPrefix(secret, info.Hint) {
|
|
t.Fatalf("token %q, hint %q", secret, info.Hint)
|
|
}
|
|
b, _ := os.ReadFile(path)
|
|
if strings.Contains(string(b), secret) || !strings.Contains(string(b), hashToken(secret)) {
|
|
t.Fatal("the file must hold the hash of the token, not the token")
|
|
}
|
|
if st, _ := os.Stat(path); st.Mode().Perm() != 0o600 {
|
|
t.Fatalf("file mode %v, want 0600", st.Mode().Perm())
|
|
}
|
|
if _, ok := s.Check(secret+"x", "10.0.0.1"); ok {
|
|
t.Fatal("a wrong token was accepted")
|
|
}
|
|
if name, ok := s.Check(secret, "10.0.0.1"); !ok || name != "n8n" {
|
|
t.Fatal("the token was refused")
|
|
}
|
|
|
|
// Reloaded from disk (restart): still valid, last use kept.
|
|
s2, err := LoadTokenStore(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if l := s2.List(); len(l) != 1 || l[0].LastIP != "10.0.0.1" || l[0].LastUsed.IsZero() {
|
|
t.Fatalf("after reload: %+v", l)
|
|
}
|
|
if _, ok := s2.Check(secret, "10.0.0.2"); !ok {
|
|
t.Fatal("token refused after reload")
|
|
}
|
|
if err := s2.Delete(info.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := s2.Check(secret, "10.0.0.2"); ok {
|
|
t.Fatal("a revoked token was accepted")
|
|
}
|
|
if err := s2.Delete(info.ID); err != errTokenNotFound {
|
|
t.Fatalf("second delete: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTokenAuth(t *testing.T) {
|
|
loginFailDelay = 0
|
|
tokens, _ := LoadTokenStore(filepath.Join(t.TempDir(), "api_tokens.json"))
|
|
_, secret, _ := tokens.Create("n8n")
|
|
|
|
var reached string
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
reached = r.URL.Path
|
|
if isViewer(r) {
|
|
reached += " viewer"
|
|
}
|
|
})
|
|
app := readOnly(mux)
|
|
login := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeAuthRequired(w) })
|
|
h := tokenAuth(tokens, login, app)
|
|
|
|
call := func(method, path, auth string) int {
|
|
reached = ""
|
|
req := httptest.NewRequest(method, path, nil)
|
|
if auth != "" {
|
|
req.Header.Set("Authorization", auth)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec.Code
|
|
}
|
|
if code := call("GET", "/api/v1/logs", "Bearer "+secret); code != http.StatusOK || reached != "/api/v1/logs viewer" {
|
|
t.Fatalf("valid token: %d, reached %q", code, reached)
|
|
}
|
|
if code := call("GET", "/api/v1/logs", "bearer "+secret); code != http.StatusOK {
|
|
t.Fatalf("lower-case scheme: %d", code)
|
|
}
|
|
for _, tc := range []struct {
|
|
method, path, auth string
|
|
want int
|
|
}{
|
|
{"GET", "/api/v1/logs", "Bearer lst_wrong", http.StatusUnauthorized},
|
|
{"GET", "/api/v1/logs", "Bearer ", http.StatusUnauthorized},
|
|
{"GET", "/api/tags", "Bearer " + secret, http.StatusForbidden},
|
|
{"GET", "/api/tokens", "Bearer " + secret, http.StatusForbidden},
|
|
{"POST", "/api/v1/logs", "Bearer " + secret, http.StatusForbidden},
|
|
{"GET", "/api/v1/logs", "", http.StatusUnauthorized}, // no token: the login decides
|
|
{"GET", "/api/v1/logs", "Basic dTpw", http.StatusUnauthorized}, // Basic goes to the login
|
|
} {
|
|
if code := call(tc.method, tc.path, tc.auth); code != tc.want || reached != "" {
|
|
t.Errorf("%s %s %q: %d (reached %q), want %d", tc.method, tc.path, tc.auth, code, reached, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// With local authentication, a token opens /api/v1/ without a session.
|
|
func TestTokenWithLocalAuth(t *testing.T) {
|
|
loginFailDelay = 0
|
|
dir := t.TempDir()
|
|
tokens, _ := LoadTokenStore(filepath.Join(dir, "api_tokens.json"))
|
|
_, secret, _ := tokens.Create("script")
|
|
app := readOnly(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) }))
|
|
auth, err := newAuth(authConfig{mode: "local", user: "admin", pass: "pw", dataDir: dir, sessionTTL: time.Hour}, app)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := tokenAuth(tokens, auth, app)
|
|
req := httptest.NewRequest("GET", "/api/v1/logs", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("without token: %d", rec.Code)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+secret)
|
|
rec = httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
|
|
t.Fatalf("with token: %d %s", rec.Code, rec.Body)
|
|
}
|
|
}
|
|
|
|
func TestTokenHandlers(t *testing.T) {
|
|
tokens, _ := LoadTokenStore(filepath.Join(t.TempDir(), "api_tokens.json"))
|
|
a := &API{tokens: tokens}
|
|
mux := http.NewServeMux()
|
|
a.Routes(mux)
|
|
do := func(r *http.Request) *httptest.ResponseRecorder {
|
|
rec := httptest.NewRecorder()
|
|
readOnly(mux).ServeHTTP(rec, r)
|
|
return rec
|
|
}
|
|
rec := do(httptest.NewRequest("POST", "/api/tokens", strings.NewReader(`{"name":"n8n"}`)))
|
|
var created struct {
|
|
ID, Token, Hint string
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &created); rec.Code != http.StatusCreated || err != nil || created.Token == "" {
|
|
t.Fatalf("create: %d %s", rec.Code, rec.Body)
|
|
}
|
|
rec = do(httptest.NewRequest("GET", "/api/tokens", nil))
|
|
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), created.Token) || strings.Contains(rec.Body.String(), "hash") {
|
|
t.Fatalf("list must not show the token or its hash: %s", rec.Body)
|
|
}
|
|
if rec := do(asViewer(httptest.NewRequest("GET", "/api/tokens", nil))); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("viewer list: %d", rec.Code)
|
|
}
|
|
if rec := do(asViewer(httptest.NewRequest("DELETE", "/api/tokens/"+created.ID, nil))); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("viewer delete: %d", rec.Code)
|
|
}
|
|
if rec := do(httptest.NewRequest("DELETE", "/api/tokens/"+created.ID, nil)); rec.Code != http.StatusNoContent {
|
|
t.Fatalf("delete: %d", rec.Code)
|
|
}
|
|
if rec := do(httptest.NewRequest("DELETE", "/api/tokens/"+created.ID, nil)); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("delete again: %d", rec.Code)
|
|
}
|
|
}
|