Files
logstream/apitokens_test.go
T
cedricandClaude Opus 5.5 c8f45044a3 Reading API /api/v1/logs with a stable cursor, and API tokens
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>
2026-11-01 15:47:57 +01:00

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)
}
}