From 139c6849f33f2140fc2b0eb279b772993fa3ec2f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 19:24:30 +0200 Subject: [PATCH] feat(web): make the house password optional for private LAN consent Signed-off-by: Fredrik Ahlgren --- .changeset/cloud-consent-lan.md | 5 ++ docs/protocol-cloud.md | 8 ++- go/internal/api/api_cloud.go | 27 +++++---- go/internal/api/api_cloud_test.go | 98 +++++++++++++++++++++++++++++-- web/project-statistics.css | 50 ++++++++++++++++ web/project-statistics.js | 69 ++++++++++++++++------ web/project-statistics.test.mjs | 62 ++++++++++++++++--- 7 files changed, 275 insertions(+), 44 deletions(-) create mode 100644 .changeset/cloud-consent-lan.md create mode 100644 web/project-statistics.css diff --git a/.changeset/cloud-consent-lan.md b/.changeset/cloud-consent-lan.md new file mode 100644 index 000000000..a880930b8 --- /dev/null +++ b/.changeset/cloud-consent-lan.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Let the direct private LAN page accept Cloud terms or statistics without requiring a house password to be set first. When password protection is on, saving the choice still requires login. Show a shorter consent card, full sharing details, a clear saved message and errors that allow a retry. diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index f1a8074a5..1993ddd5f 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -1407,7 +1407,7 @@ consent text. | `GET /api/cloud` | An owner's session, or a client on the LAN. State, scopes, purposes, bytes sent in the last 24 hours of uptime, whether the byte budget is reached | | `GET /api/cloud/stats-policy` | The same. Exact fields, purpose and group rules for a fresh consent | | `GET /api/cloud/preview` | The same. The exact bytes that leave the house | -| `POST /api/cloud/grant {scopes, policy}` | An owner's session. On the LAN, only from loopback or with the house password. On a grant already on, it changes the scopes and keeps the key, home id and `grantKey` | +| `POST /api/cloud/grant {scopes, policy}` | An owner's session, the box itself, or a direct private LAN client. The optional house password, when on, requires a LAN login as Settings does. On a grant already on, it changes the scopes and keeps the key, home id and `grantKey` | | `POST /api/cloud/stats {policy}` | The same gate, as a separate consent. 409 while the grant is off or a revoke waits for its receipt | | `DELETE /api/cloud/grant`, `DELETE /api/cloud/stats` | An owner's session, or any client on the LAN, because narrowing access is safe | @@ -1415,6 +1415,12 @@ A viewer's session gets 403 on every route, and so does a request with forwarding headers. No route asks for a step-up: the box cannot verify the app's, so the grant screen claims no more than an owner's session. +The house password is not a prerequisite for consent. With it off, a client +that can reach the box on a private network may turn Cloud or statistics on, +as it may save settings and pair an owner. This includes private VPN peers. +Public clients, public hostnames, forwarding headers and browser cross-site +writes do not gain access. CGNAT is not a private network for this choice. + - Turning FTW Cloud on pushes `cloud.granted` to all owners, and turning it off pushes `cloud.revoked`. A change of scopes, and turning statistics on or off, push nothing. Every change writes an audit line on the box. A grant, diff --git a/go/internal/api/api_cloud.go b/go/internal/api/api_cloud.go index 6b9bb28a6..331d9a3bc 100644 --- a/go/internal/api/api_cloud.go +++ b/go/internal/api/api_cloud.go @@ -11,15 +11,14 @@ import ( "github.com/srcfl/ftw/go/internal/loadpoint" ) -// FTW Cloud, at the HTTP door. +// FTW Cloud over HTTP. // -// The grant lets the box send a readable copy of the house to a Sourceful -// service, so who may turn it on is the narrowest door this API has: an owner -// over an app session, or on the LAN only from the box itself or with the -// house password. Turning it off narrows access, so any door may do that. The -// box cannot verify the app's step-up, so the grant asks for none and the -// app's consent screen claims no more than an owner's session. Statistics are -// a second consent under the grant, behind the same doors. +// A grant lets the box send a readable copy of the house to Sourceful, so +// an owner must accept it. LAN consent follows the private network and +// optional house-password rules used by Settings and owner pairing. +// Turning Cloud off narrows access, so any local client may do that. +// The box cannot verify the app's step-up, so a grant requires an owner's +// session without claiming a separate step-up. Statistics use the same check. // CloudGrant is the box's FTW Cloud link as the routes see it. type CloudGrant interface { @@ -43,8 +42,9 @@ func (s *Server) cloudRoutes() { s.handle("DELETE /api/cloud/stats", Configure, s.handleCloudStopStats, NoStepUp) } -// cloudGate admits an owner's session, and on the LAN a local client. With -// toGrant, the LAN needs more: the box itself, or the house password. +// cloudGate admits an owner's session, and on the LAN a local client. A grant +// follows the private LAN's settings rule: the house password is optional, +// but when it is on the caller must have proved it. func (s *Server) cloudGate(w http.ResponseWriter, r *http.Request, toGrant bool) bool { if hasForwardingHeader(r.Header) { writeAppLinkError(w, http.StatusForbidden, "FTW Cloud is managed in the app or on your home network") @@ -69,8 +69,11 @@ func (s *Server) cloudGate(w http.ResponseWriter, r *http.Request, toGrant bool) if ok, checked := lanSecretFrom(r.Context()); checked && ok { return true } + if !s.lanAuthEnabled() && isPrivateClient(r.RemoteAddr) { + return true + } writeAppLinkError(w, http.StatusForbidden, - "turn FTW Cloud on in the app, or on your home network with the house password on") + "log in with the house password to save this choice, or use the owner's app") return false } @@ -144,7 +147,7 @@ func (s *Server) handleCloudGrant(w http.ResponseWriter, r *http.Request) { case errors.Is(err, cloudlink.ErrNoService): writeAppLinkError(w, http.StatusServiceUnavailable, err.Error()) default: - writeAppLinkError(w, http.StatusInternalServerError, "could not turn FTW Cloud on") + writeAppLinkError(w, http.StatusInternalServerError, "could not save Cloud terms. Try again.") } } diff --git a/go/internal/api/api_cloud_test.go b/go/internal/api/api_cloud_test.go index 969be28e2..e4c064bc1 100644 --- a/go/internal/api/api_cloud_test.go +++ b/go/internal/api/api_cloud_test.go @@ -66,9 +66,8 @@ func loopbackRequest(method, target, body string) *http.Request { const grantBody = `{"scopes":["ftw.live.read"],"policy":"2026-10"}` -// Turning FTW Cloud on is an owner's act: over an app session, or at the box. -// A LAN client without the house password is refused, unlike the rest of the -// LAN API, because this sends the house out of it. +// Turning FTW Cloud on follows the box's owner rules. With the optional house +// password off, a direct private LAN client may consent, as it may save settings. func TestWhoMayTurnFTWCloudOn(t *testing.T) { cases := []struct { name string @@ -78,7 +77,7 @@ func TestWhoMayTurnFTWCloudOn(t *testing.T) { {"owner over a session", sessionRequest(http.MethodPost, "/api/cloud/grant", grantBody), http.StatusOK}, {"viewer over a session", viewerRequest(http.MethodPost, "/api/cloud/grant", grantBody), http.StatusForbidden}, {"the box itself", loopbackRequest(http.MethodPost, "/api/cloud/grant", grantBody), http.StatusOK}, - {"a LAN client", localRequest(http.MethodPost, "/api/cloud/grant", grantBody), http.StatusForbidden}, + {"a LAN client", localRequest(http.MethodPost, "/api/cloud/grant", grantBody), http.StatusOK}, {"a LAN client with the house password", func() *http.Request { r := localRequest(http.MethodPost, "/api/cloud/grant", grantBody) return r.WithContext(withLANSecret(r.Context(), true)) @@ -105,6 +104,95 @@ func TestWhoMayTurnFTWCloudOn(t *testing.T) { } } +func TestCloudConsentUsesTheConfiguredLANProtection(t *testing.T) { + for _, choice := range []struct{ path, body string }{ + {"/api/cloud/grant", grantBody}, + {"/api/cloud/stats", statsBody}, + } { + t.Run(choice.path, func(t *testing.T) { + resetLANGuesses(t) + s := newLANAuthServer(t) + cloud := &stubCloud{} + s.deps.Cloud = cloud + attempt := func(req *http.Request, want, calls int) { + t.Helper() + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, req) + if w.Code != want { + t.Fatalf("got %d, want %d: %s", w.Code, want, w.Body.String()) + } + if got := len(cloud.granted) + len(cloud.stats); got != calls { + t.Fatalf("Cloud writes = %d, want %d", got, calls) + } + } + attempt(localRequest(http.MethodPost, choice.path, choice.body), http.StatusOK, 1) + enableStoredLANAuth(t, s) + attempt(localRequest(http.MethodPost, choice.path, choice.body), http.StatusUnauthorized, 1) + + login := postLANAuthJSON(s, http.MethodPost, "http://ftw.local:8080/api/auth/login", "192.168.1.10:43210", + `{"password":"`+testHousePassword+`"}`) + if login.Code != http.StatusOK { + t.Fatalf("login = %d: %s", login.Code, login.Body.String()) + } + req := localRequest(http.MethodPost, choice.path, choice.body) + req.AddCookie(&http.Cookie{Name: lanSessionCookieName, Value: lanSessionFromRecorder(t, login)}) + attempt(req, http.StatusOK, 2) + + req = localRequest(http.MethodPost, choice.path, choice.body) + req.Header.Set("Authorization", "Bearer "+testHousePassword) + attempt(req, http.StatusOK, 3) + req = localRequest(http.MethodPost, choice.path, choice.body) + req.Header.Set("Authorization", "Bearer wrong-password") + attempt(req, http.StatusUnauthorized, 3) + }) + } +} + +func TestCloudConsentWithoutAPasswordStaysOnThePrivateNetwork(t *testing.T) { + for _, choice := range []struct{ path, body string }{ + {"/api/cloud/grant", grantBody}, + {"/api/cloud/stats", statsBody}, + } { + t.Run(choice.path, func(t *testing.T) { + for _, tc := range []struct { + name, host, remote, header, value string + want int + }{ + {"private IPv4", "ftw.local:8080", "192.168.1.10:1234", "", "", http.StatusOK}, + {"private IPv6", "[fd00::1]:8080", "[fd00::2]:1234", "", "", http.StatusOK}, + {"CGNAT", "ftw.local:8080", "100.64.0.10:1234", "", "", http.StatusForbidden}, + {"public client", "ftw.local:8080", "203.0.113.9:1234", "", "", http.StatusForbidden}, + {"public host", "box.example.com", "192.168.1.10:1234", "", "", http.StatusForbidden}, + {"forwarded LAN", "ftw.local:8080", "192.168.1.10:1234", "X-Forwarded-For", "192.168.1.20", http.StatusForbidden}, + {"loopback proxy", "localhost:8080", "127.0.0.1:1234", "Forwarded", "for=203.0.113.9", http.StatusForbidden}, + {"cross-site origin", "ftw.local:8080", "192.168.1.10:1234", "Origin", "https://other.example", http.StatusForbidden}, + {"cross-site fetch", "ftw.local:8080", "192.168.1.10:1234", "Sec-Fetch-Site", "cross-site", http.StatusForbidden}, + {"non-JSON body", "ftw.local:8080", "192.168.1.10:1234", "Content-Type", "text/plain", http.StatusUnsupportedMediaType}, + } { + t.Run(tc.name, func(t *testing.T) { + cloud := &stubCloud{} + s := New(&Deps{Cloud: cloud}) + req := localRequest(http.MethodPost, choice.path, choice.body) + req.Host, req.RemoteAddr = tc.host, tc.remote + req.Header.Set("Content-Type", "application/json") + if tc.header != "" { + req.Header.Set(tc.header, tc.value) + } + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, req) + if w.Code != tc.want { + t.Fatalf("got %d, want %d: %s", w.Code, tc.want, w.Body.String()) + } + if called := len(cloud.granted)+len(cloud.stats) == 1; called != (tc.want == http.StatusOK) { + t.Fatalf("Cloud write = %v with status %d", called, w.Code) + } + }) + } + }) + } +} + // The audit line names the door, never anything the caller sent. func TestTheGrantRecordsWhichDoorAsked(t *testing.T) { cloud := &stubCloud{} @@ -213,7 +301,7 @@ func TestWhoMayTurnStatisticsOn(t *testing.T) { {"owner over a session", sessionRequest(http.MethodPost, "/api/cloud/stats", statsBody), http.StatusOK}, {"viewer over a session", viewerRequest(http.MethodPost, "/api/cloud/stats", statsBody), http.StatusForbidden}, {"the box itself", loopbackRequest(http.MethodPost, "/api/cloud/stats", statsBody), http.StatusOK}, - {"a LAN client", localRequest(http.MethodPost, "/api/cloud/stats", statsBody), http.StatusForbidden}, + {"a LAN client", localRequest(http.MethodPost, "/api/cloud/stats", statsBody), http.StatusOK}, {"a LAN client with the house password", func() *http.Request { r := localRequest(http.MethodPost, "/api/cloud/stats", statsBody) return r.WithContext(withLANSecret(r.Context(), true)) diff --git a/web/project-statistics.css b/web/project-statistics.css new file mode 100644 index 000000000..ac1af97c4 --- /dev/null +++ b/web/project-statistics.css @@ -0,0 +1,50 @@ +.project-statistics-panel { + min-inline-size: 0; + margin: 0; + padding: 16px 20px 20px; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--ink-raised); + color: var(--fg); +} +.project-statistics-panel legend { + padding: 0 6px; + color: var(--fg); + font: 600 1rem var(--sans); +} +.project-statistics-panel .hint { + max-width: 85ch; + margin: 10px 0; + color: var(--fg-dim); + font: inherit; + line-height: 1.5; +} +.project-statistics-panel details { + margin: 12px 0; +} +.project-statistics-panel summary { + width: fit-content; + color: var(--fg); + cursor: pointer; +} +.project-statistics-panel a { + color: var(--accent-e); +} +.project-statistics-panel .app-link-actions { + margin-top: 16px; +} +.project-statistics-panel button { + min-height: 40px; +} +.project-statistics-panel .project-statistics-error { + margin: 12px 0 0; + color: var(--red-e); +} +.project-statistics-saved { + margin: 0; + padding: 12px 16px; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--ink-raised); + color: var(--fg); +} diff --git a/web/project-statistics.js b/web/project-statistics.js index 7f56b0cde..b7af63748 100644 --- a/web/project-statistics.js +++ b/web/project-statistics.js @@ -4,7 +4,7 @@ var read = window.fetch.bind(window); var details = "Recorded hourly energy, configured battery capacity, battery and charger power limits, and solar capacity help improve FTW and publish group totals. Values are rounded to 10 Wh or W. No names, addresses or device ids."; var privacy = "Sourceful links reports to this box for deletion. They are not anonymous and stay for 48 hours. Public values need at least 20 homes, with no home above 30% of a sum. Turning sharing off deletes unpublished reports when the box reaches the collector. Published totals remain."; - var status, policy, justAnswered=false; + var status, policy, authStatus, justAnswered=false, saving=false, refreshNumber=0; function node(tag, text, parent) { var el = document.createElement(tag); if (text) el.textContent = text; @@ -12,11 +12,24 @@ return el; } function say(parent, text) { var p=node("p",text,parent);p.className="hint";return p; } - function button(parent,text,run,primary) { + function button(parent,text,run,primary,feedback) { var b=node("button",text,parent);b.type="button";b.className=primary?"btn-send":"btn-ghost"; b.addEventListener("click",function(){ + if(saving)return; + saving=true; + document.querySelectorAll(".project-statistics-panel button").forEach(function(button){button.disabled=true;}); b.disabled=true; - Promise.resolve().then(run).then(refresh).catch(function(e){say(parent,e.message||"The choice could not be saved. Check the box status.");}).finally(function(){b.disabled=false;}); + b.textContent="Saving…"; + feedback.textContent=""; + feedback.hidden=true; + Promise.resolve().then(run).then(function(){saving=false;return refresh();}).catch(function(e){ + feedback.textContent=e.message||"The choice could not be saved. Try again."; + feedback.hidden=false; + }).finally(function(){ + saving=false; + document.querySelectorAll(".project-statistics-panel button").forEach(function(button){button.disabled=false;}); + b.disabled=false;b.textContent=text; + }); }); } function write(method,path,body) { @@ -27,44 +40,61 @@ if (!status || !policy || (!settings && !status.statisticsDecisionRequired && !justAnswered)) {host.hidden=true;return;} host.hidden=false; if(!settings && !status.statisticsDecisionRequired && justAnswered) { - say(host,status.link==="ending"?"Stopping Cloud and deleting its stored copy. The box continues locally.":status.statisticsActive?"Statistics choice saved. Hourly reports wait for approval; change this in Settings → FTW app.":"Statistics sharing is off. The box continues locally.");return; + var saved=status.link==="ending"?"Stopping Cloud and deleting its stored copy. The box continues locally.":status.cloudTermsAccepted?"Cloud terms accepted and saved on this box.":status.statisticsActive?"Statistics choice saved. Change it in Settings → FTW app.":"Statistics sharing is off. The box continues locally."; + var receipt=say(host,saved);receipt.className="project-statistics-saved";receipt.setAttribute("role","status"); + return; } var panel=node("fieldset",null,host); + panel.className="project-statistics-panel"; var cloud=status.link==="on" && !status.statisticsOnly; - node("legend",cloud?"FTW Cloud includes statistics":"Contribute to FTW statistics?",panel); - if(cloud) say(panel,"Everyone using FTW Cloud contributes to project statistics. Accept the updated Cloud terms to use the app and remote access. To stop contributing, turn FTW Cloud off and use the box locally."); + node("legend",cloud?(status.cloudTermsAccepted?"FTW Cloud":"Review FTW Cloud terms"):"Contribute to FTW statistics?",panel); + if(cloud) say(panel,status.cloudTermsAccepted?"The current Cloud terms are accepted. FTW Cloud includes project statistics.":"FTW Cloud includes project statistics. Sourceful can read its copy of your energy data. Accept the updated terms for the app and remote access, or turn Cloud off and keep using the box locally."); else say(panel,"For local FTW use, sharing is your choice. Yes is the suggested choice. Nothing starts until you answer, and saying no leaves local operation working. Statistics-only sharing gives no app, phone or assistant remote access."); - say(panel,details); - say(panel,privacy); + say(panel,"Shares recorded hourly energy and configured system capacities. Reports stay for 48 hours; public totals require at least 20 homes."); + var disclosure=node("details",null,panel); + node("summary","What is shared and how it is used",disclosure); + say(disclosure,details); + say(disclosure,privacy); var link=node("a","Privacy and Cloud terms",panel);link.href="https://ftw.sourceful.energy/privacy#statistics";link.target="_blank";link.rel="noopener"; - if(!policy.available) say(panel,"Hourly collection is waiting for privacy review and activation. Your choice can be saved now; no hourly report leaves before that approval."); + if(!policy.available) say(panel,"You can save your choice now. Hourly reports wait for privacy review and activation."); + if(!status.cloudTermsAccepted) { + say(panel,authStatus && authStatus.lan_auth + ?"Your house password protects this choice. The box will ask you to log in if needed." + :authStatus && authStatus.lan_auth===false + ?"No house password is required. With password protection off, anyone who can reach this box on your private network can save this choice." + :"This choice uses the same sign-in rules as Settings. A house password is optional."); + } var actions=node("div",null,panel);actions.className="app-link-actions"; + var feedback=node("p",null,panel);feedback.className="project-statistics-error";feedback.setAttribute("role","alert");feedback.hidden=true; if(status.link==="ending") {say(panel,"Cloud is stopping and deleting its stored copy. Refresh after the box receives the deletion receipt.");return;} if(cloud) { if(status.cloudTermsAccepted) say(panel,"The current Cloud terms include statistics for this box."); - else button(actions,"Accept Cloud terms",function(){return write("POST","/api/cloud/grant",{scopes:policy.cloud_scopes,policy:policy.cloud_policy});},true); + else button(actions,"Accept Cloud terms",function(){return write("POST","/api/cloud/grant",{scopes:policy.cloud_scopes,policy:policy.cloud_policy});},true,feedback); button(actions,"Turn FTW Cloud off",function(){ if(!window.confirm("Turn FTW Cloud off? The app and assistants lose remote access. The box keeps working locally. You may need to pair phones again if you turn Cloud back on."))return; return write("DELETE","/api/cloud"); - },false); + },false,feedback); } else if(status.statisticsOnly) { say(panel,"Local statistics sharing is on. Remote Cloud functions are off."); - button(actions,"Stop sharing statistics",function(){return write("DELETE","/api/cloud/stats");},false); + button(actions,"Stop sharing statistics",function(){return write("DELETE","/api/cloud/stats");},false,feedback); } else { - button(actions,"Yes, contribute",function(){return write("POST","/api/cloud/stats",{policy:policy.policy});},true); - if(status.statisticsDecisionRequired) button(actions,"No, keep it local",function(){return write("DELETE","/api/cloud/stats");},false); + button(actions,"Yes, contribute",function(){return write("POST","/api/cloud/stats",{policy:policy.policy});},true,feedback); + if(status.statisticsDecisionRequired) button(actions,"No, keep it local",function(){return write("DELETE","/api/cloud/stats");},false,feedback); else say(panel,"Local statistics sharing is off."); } if(settings && !cloud) { var more=node("details",null,panel);node("summary","Use FTW Cloud and the app",more); say(more,"FTW Cloud adds app and assistant remote access and a readable state copy. Statistics are included for every Cloud user. Read the information above before accepting."); - button(more,"Accept Cloud terms and turn it on",function(){return write("POST","/api/cloud/grant",{scopes:policy.cloud_scopes,policy:policy.cloud_policy});},true); + button(more,"Accept Cloud terms and turn it on",function(){return write("POST","/api/cloud/grant",{scopes:policy.cloud_scopes,policy:policy.cloud_policy});},true,feedback); } } function refresh() { - return Promise.all([read("/api/cloud"),read("/api/cloud/stats-policy")]).then(function(rs){if(rs.some(function(r){return !r.ok;}))return null;return Promise.all(rs.map(function(r){return r.json();}));}).then(function(v){ - if(!v)return; - status=v[0];policy=v[1]; + var current=++refreshNumber; + return Promise.all([read("/api/cloud"),read("/api/cloud/stats-policy"),read("/api/auth/status").catch(function(){return null;})]).then(function(rs){ + return Promise.all(rs.map(function(r){return r && r.ok?r.json().catch(function(){return null;}):null;})); + }).then(function(v){ + if(current!==refreshNumber || saving || !v[0] || !v[1])return; + status=v[0];policy=v[1];authStatus=v[2]; if(!policy.cloud_includes_statistics || !policy.standalone)return; var prompt=document.getElementById("project-statistics-prompt"); if(!prompt){ @@ -80,6 +110,9 @@ document.querySelectorAll("[data-project-statistics-settings]").forEach(function(host){paint(host,true);}); }).catch(function(){}); } + if(!document.getElementById("project-statistics-styles")) { + var styles=node("link");styles.id="project-statistics-styles";styles.rel="stylesheet";styles.href="/project-statistics.css";document.head.appendChild(styles); + } window.FTWStatistics={refresh:refresh,cloudAccepted:function(){return !!(status && status.cloudTermsAccepted);},mount:function(host){if(status&&policy)paint(host,true);refresh();}}; window.addEventListener("ftw-owner-ready",refresh); if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",refresh);else refresh(); diff --git a/web/project-statistics.test.mjs b/web/project-statistics.test.mjs index 55716d0d0..6fa771ea0 100644 --- a/web/project-statistics.test.mjs +++ b/web/project-statistics.test.mjs @@ -5,28 +5,38 @@ import vm from 'node:vm'; import {setImmediate} from 'node:timers/promises'; const source=readFileSync(new URL('./project-statistics.js',import.meta.url),'utf8'); -function page(cloud=false,authorised=true) { +function page(cloud=false,authorised=true,options={}) { class Element { - constructor(tag){this.tag=tag;this.children=[];this.events={};this.style={};this.ownText='';} + constructor(tag){this.tag=tag;this.children=[];this.events={};this.style={};this.attributes={};this.ownText='';} appendChild(e){this.children.push(e);return e;} insertBefore(e){this.children.unshift(e);} set textContent(v){this.ownText=v;this.children=[];} get textContent(){return this.ownText+this.children.map(e=>e.textContent).join(' ');} - setAttribute(){} + setAttribute(name,value){this.attributes[name]=value;} addEventListener(k,f){this.events[k]=f;} } const main=new Element('main'); - const all=()=>{const out=[];const visit=e=>{out.push(e);e.children.forEach(visit);};visit(main);return out;}; + const head=new Element('head'); + const all=()=>{const out=[];const visit=e=>{out.push(e);e.children.forEach(visit);};visit(main);visit(head);return out;}; let state={link:cloud?'on':'off',statisticsDecisionRequired:true,statisticsOnly:false}; const writes=[]; const policy={policy:'stats-current',cloud_policy:'cloud-current',cloud_scopes:['ftw.live.read'],available:false,cloud_includes_statistics:true,standalone:true}; const window={addEventListener(){},confirm:()=>true,fetch:async(path,req)=>{ - if(req){writes.push({path,...req});state={...state,statisticsDecisionRequired:false,statisticsActive:req.method==='POST'};} + if(path==='/api/auth/status') { + if(options.authError)throw Error('status unavailable'); + return {ok:true,json:async()=>({lan_auth:options.lanAuth===true})}; + } + if(req){ + writes.push({path,...req}); + if(options.refreshDuringWrite)await window.FTWStatistics.refresh(); + if(options.writeError)return {ok:false,json:async()=>({error:options.writeError})}; + state={...state,statisticsDecisionRequired:false,statisticsActive:req.method==='POST',cloudTermsAccepted:path==='/api/cloud/grant' && req.method==='POST'}; + } return {ok:authorised,json:async()=>path.endsWith('stats-policy')?policy:state}; }}; - const document={readyState:'loading',addEventListener(){},createElement:t=>new Element(t),getElementById:id=>all().find(e=>e.id===id),querySelector:()=>main,querySelectorAll:()=>[]}; + const document={head,readyState:'loading',addEventListener(){},createElement:t=>new Element(t),getElementById:id=>all().find(e=>e.id===id),querySelector:()=>main,querySelectorAll:()=>[]}; vm.runInNewContext(source,{window,document,Promise,Error}); - return {window,writes,main,buttons:()=>all().filter(e=>e.tag==='button')}; + return {window,writes,main,all,buttons:()=>all().filter(e=>e.tag==='button')}; } test('local question sends nothing before a real Yes or No',async()=>{ @@ -49,10 +59,46 @@ test('local Yes grants only statistics, not Cloud access',async()=>{ test('Cloud has bundled terms and a whole-service off action',async()=>{ const p=page(true);await p.window.FTWStatistics.refresh(); assert.deepEqual(p.buttons().map(e=>e.textContent),['Accept Cloud terms','Turn FTW Cloud off']); - assert.match(p.main.textContent,/Everyone using FTW Cloud contributes/); + assert.match(p.main.textContent,/FTW Cloud includes project statistics/); + assert.match(p.main.textContent,/No house password is required/); + assert.equal(p.writes.length,0); p.buttons()[0].events.click();await setImmediate(); assert.equal(p.writes.length,1);assert.equal(p.writes[0].path,'/api/cloud/grant'); assert.equal(JSON.parse(p.writes[0].body).policy,'cloud-current'); + assert.match(p.main.textContent,/Cloud terms accepted and saved on this box/); +}); + +test('a protected box explains sign-in without asking for password setup',async()=>{ + const p=page(true,true,{lanAuth:true});await p.window.FTWStatistics.refresh(); + assert.match(p.main.textContent,/house password protects this choice/); + assert.match(p.main.textContent,/ask you to log in if needed/); + assert.equal(p.writes.length,0); + p.buttons()[0].events.click();await setImmediate(); + assert.deepEqual(p.writes.map(w=>w.path),['/api/cloud/grant']); +}); + +test('a failed save shows one error and lets the owner retry',async()=>{ + const p=page(true,true,{writeError:'The choice could not be stored.',refreshDuringWrite:true}); + await p.window.FTWStatistics.refresh(); + for(let i=0;i<2;i++) { + p.buttons()[0].events.click(); + assert.equal(p.buttons()[0].textContent,'Saving…'); + await setImmediate(); + assert.equal(p.buttons()[0].disabled,false); + assert.equal(p.buttons()[0].textContent,'Accept Cloud terms'); + const errors=p.all().filter(e=>e.className==='project-statistics-error'); + assert.equal(errors.length,1); + assert.equal(errors[0].hidden,false); + assert.equal(errors[0].attributes.role,'alert'); + assert.equal(errors[0].textContent,'The choice could not be stored.'); + } + assert.equal(p.writes.length,2); +}); + +test('missing password status does not block the consent choice',async()=>{ + const p=page(true,true,{authError:true});await p.window.FTWStatistics.refresh(); + assert.match(p.main.textContent,/house password is optional/); + assert.equal(p.buttons()[0].textContent,'Accept Cloud terms'); }); test('an unauthenticated page neither asks nor writes on behalf of an owner',async()=>{