Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/classroom/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,30 @@ One Tap が表示できない環境(Windows や Playwright では毎回)で
| `POST` | `/classrooms/google-import` | コースをインポートして課題を作成(`Classrooms` に書き込む) |
| `POST` | `/classrooms/{id}/google-assignment` | Google Classroom に課題を投稿 |

#### Google API 失敗時のレスポンス

Google Classroom API の失敗は、Google が返した `error.status` / `error.message` を
そのまま透過し、UI が翻訳するための安定キー `reason` を付けて返す
(詳細は CloudWatch にも `Google Classroom API error` として記録する。アクセストークンは記録しない)。

| Google の HTTP | 返す HTTP | `reason` | 典型的な原因 |
|---|---|---|---|
| 401 | 401 | `googleTokenExpired` | Google のアクセストークンが失効 |
| 403 | 403 | `googlePermissionDenied` | 権限(スコープ)不足 / そのコースの先生ではない |
| 404 | 404 | `googleCourseNotFound` | ひもづけたコースが削除された / そのコースにアクセスできない |
| 400 | 400 | `googleInvalidRequest` | Google がリクエストを拒否(`FAILED_PRECONDITION` 等を含む) |
| その他 | 502 | `googleApiError` | レート超過・Google 側障害など |

レスポンス本文: `{error, reason, googleStatus?, googleMessage?}`。

クライアントが取得するアクセストークンは `classroom.coursework.students` を含む 3 スコープを要求するが、
Google の同意画面はスコープごとにチェックボックスを出すため**一部だけ許可される**ことがある。
クライアントは `hasGrantedAllScopes` で検証し、不足していれば `prompt: 'consent'` で再要求する。
再要求してもなお不足する場合に加え、**再要求のポップアップがブラウザにブロックされた / 先生が
閉じた・拒否した場合**も `googlePermissionDenied` として扱う(再要求は最初のクリックから時間が
経っているため、ブラウザがポップアップを開かないことがある)。ただし OAuth の state 不一致
(CSRF の疑い)はこの扱いに含めず、そのままエラーにする。

## データモデル

### Classrooms テーブル
Expand Down
2 changes: 2 additions & 0 deletions docs/classroom/source-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ CDK スタック定義。以下のリソースを作成:
| `verifyTeacherToken()` | Google ID Token 検証 |
| `verifySessionToken()` | Session Token 検証 |
| `callGoogleClassroomAPI()` | Google Classroom API プロキシ |
| `parseGoogleApiErrorBody()` | Google のエラー本文から `status` / `message` を取り出す(失敗時は空文字) |
| `googleApiErrorResponse()` | Google の HTTP ステータスを自前のレスポンス(`reason` 付き)へ写像 |
| `getCorsHeaders()` | CORS ヘッダー生成 |
| `checkJoinRateLimit()` | レート制限チェック |
| `generateJoinCode()` | 参加コード生成 (6文字英数字) |
Expand Down
116 changes: 107 additions & 9 deletions infra/smalruby-classroom/lambda/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,10 +348,17 @@ class ConflictError extends Error {

class GoogleAPIError extends Error {
statusCode: number;
constructor(statusCode: number, message: string) {
// Google's own error envelope (`error.status` / `error.message`), kept so the
// response and the CloudWatch log can name the actual cause instead of the
// opaque "Google Classroom API error" the teacher used to see (issue #1175).
googleStatus: string;
googleMessage: string;
constructor(statusCode: number, message: string, googleStatus = '', googleMessage = '') {
super(message);
this.name = 'GoogleAPIError';
this.statusCode = statusCode;
this.googleStatus = googleStatus;
this.googleMessage = googleMessage;
}
}

Expand Down Expand Up @@ -380,6 +387,86 @@ const KICK_TOMBSTONE_TTL_SECONDS = parseInt(process.env.KICK_TOMBSTONE_TTL_SECON

// --- Google Classroom API proxy ---

/** Max length of a Google error message we keep (logs + API response). */
const GOOGLE_ERROR_MESSAGE_MAX = 300;

/**
* Pull `status` / `message` out of a Google API error envelope:
* `{"error":{"code":404,"message":"...","status":"NOT_FOUND"}}`.
*
* Google also answers with HTML (front-end proxies, 5xx) and with bodies that
* carry no envelope, so every failure mode degrades to empty strings instead of
* throwing — this runs inside an error path and must never mask the original
* failure.
*/
export function parseGoogleApiErrorBody(raw: string): { googleStatus: string; googleMessage: string } {
const empty = { googleStatus: '', googleMessage: '' };
if (!raw) return empty;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return empty;
}
const error = (parsed as { error?: unknown } | null)?.error;
if (!error || typeof error !== 'object') return empty;
const { status, message } = error as { status?: unknown; message?: unknown };
return {
googleStatus: typeof status === 'string' ? status : '',
googleMessage: typeof message === 'string' ? message.slice(0, GOOGLE_ERROR_MESSAGE_MAX) : '',
};
}

/**
* Map a Google Classroom failure onto our API response.
*
* Before issue #1175 everything that was not 401/403 collapsed into a bare 502
* "Google Classroom API error", which told the teacher nothing and left no way
* to tell "the linked course is gone / you are not a teacher of it" (404) apart
* from a malformed request (400). The Google-provided status and message are
* passed through so the UI can show an actionable message, and `reason` gives
* the UI a stable key to translate.
*/
export function googleApiErrorResponse(
statusCode: number,
googleStatus: string,
googleMessage: string,
): { statusCode: number; body: string } {
let httpStatus: number;
let reason: string;
let error: string;
if (statusCode === 401) {
httpStatus = 401;
reason = 'googleTokenExpired';
error = 'Google access token expired. Please re-authorize.';
} else if (statusCode === 403) {
httpStatus = 403;
reason = 'googlePermissionDenied';
error = 'Insufficient Google Classroom permissions.';
} else if (statusCode === 404) {
httpStatus = 404;
reason = 'googleCourseNotFound';
error = 'Google Classroom course not found, or you are not a teacher of it.';
} else if (statusCode === 400) {
httpStatus = 400;
reason = 'googleInvalidRequest';
error = 'Google Classroom rejected the request.';
} else {
httpStatus = 502;
reason = 'googleApiError';
error = 'Google Classroom API error';
}
return {
statusCode: httpStatus,
body: JSON.stringify({
error,
reason,
...(googleStatus ? { googleStatus } : {}),
...(googleMessage ? { googleMessage } : {}),
}),
};
}

async function callGoogleClassroomAPI(
accessToken: string,
path: string,
Expand All @@ -396,7 +483,23 @@ async function callGoogleClassroomAPI(
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
throw new GoogleAPIError(response.status, `Google Classroom API error: ${response.status}`);
const raw = await response.text().catch(() => '');
const { googleStatus, googleMessage } = parseGoogleApiErrorBody(raw);
// Logged (never the access token) so a teacher report like issue #1175 can
// be diagnosed from CloudWatch without reproducing it.
console.error('Google Classroom API error', {
method,
path,
httpStatus: response.status,
googleStatus,
googleMessage,
});
throw new GoogleAPIError(
response.status,
`Google Classroom API error: ${response.status}`,
googleStatus,
googleMessage,
);
}
return response.json();
}
Expand Down Expand Up @@ -5070,13 +5173,8 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGateway
};
}
if (err instanceof GoogleAPIError) {
if (err.statusCode === 401) {
return { statusCode: 401, headers: corsHeaders, body: JSON.stringify({ error: 'Google access token expired. Please re-authorize.' }) };
}
if (err.statusCode === 403) {
return { statusCode: 403, headers: corsHeaders, body: JSON.stringify({ error: 'Insufficient Google Classroom permissions.' }) };
}
return { statusCode: 502, headers: corsHeaders, body: JSON.stringify({ error: 'Google Classroom API error' }) };
const mapped = googleApiErrorResponse(err.statusCode, err.googleStatus, err.googleMessage);
return { ...mapped, headers: corsHeaders };
}
if (err instanceof SyntaxError) {
return { statusCode: 400, headers: corsHeaders, body: JSON.stringify({ error: 'Invalid JSON body' }) };
Expand Down
102 changes: 102 additions & 0 deletions infra/smalruby-classroom/lambda/tests/handler-google-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { parseGoogleApiErrorBody, googleApiErrorResponse } from '../handler';

describe('parseGoogleApiErrorBody', () => {
test('extracts status and message from a Google error envelope', () => {
const raw = JSON.stringify({
error: {
code: 404,
message: 'Requested entity was not found.',
status: 'NOT_FOUND',
},
});
expect(parseGoogleApiErrorBody(raw)).toEqual({
googleStatus: 'NOT_FOUND',
googleMessage: 'Requested entity was not found.',
});
});

test('returns empty fields for a non-JSON body', () => {
expect(parseGoogleApiErrorBody('<html>502 Bad Gateway</html>')).toEqual({
googleStatus: '',
googleMessage: '',
});
});

test('returns empty fields for an empty body', () => {
expect(parseGoogleApiErrorBody('')).toEqual({ googleStatus: '', googleMessage: '' });
});

test('tolerates a JSON body without the error envelope', () => {
expect(parseGoogleApiErrorBody(JSON.stringify({ foo: 'bar' }))).toEqual({
googleStatus: '',
googleMessage: '',
});
});

test('ignores non-string status / message fields', () => {
const raw = JSON.stringify({ error: { status: 42, message: { nested: true } } });
expect(parseGoogleApiErrorBody(raw)).toEqual({ googleStatus: '', googleMessage: '' });
});

test('truncates an overly long message', () => {
const raw = JSON.stringify({ error: { status: 'INVALID_ARGUMENT', message: 'あ'.repeat(600) } });
const parsed = parseGoogleApiErrorBody(raw);
expect(parsed.googleStatus).toBe('INVALID_ARGUMENT');
expect(parsed.googleMessage.length).toBeLessThanOrEqual(300);
});
});

describe('googleApiErrorResponse', () => {
const parse = (res: { statusCode: number; body: string }) => JSON.parse(res.body);

test('401 keeps the re-authorization hint and flags googleTokenExpired', () => {
const res = googleApiErrorResponse(401, 'UNAUTHENTICATED', 'Invalid Credentials');
expect(res.statusCode).toBe(401);
expect(parse(res).reason).toBe('googleTokenExpired');
});

test('403 flags googlePermissionDenied', () => {
const res = googleApiErrorResponse(403, 'PERMISSION_DENIED', 'The caller does not have permission');
expect(res.statusCode).toBe(403);
expect(parse(res).reason).toBe('googlePermissionDenied');
});

test('404 is surfaced as 404 googleCourseNotFound, not a generic 502', () => {
const res = googleApiErrorResponse(404, 'NOT_FOUND', 'Requested entity was not found.');
expect(res.statusCode).toBe(404);
const body = parse(res);
expect(body.reason).toBe('googleCourseNotFound');
expect(body.googleStatus).toBe('NOT_FOUND');
expect(body.googleMessage).toBe('Requested entity was not found.');
});

test('400 is surfaced as 400 googleInvalidRequest with Google detail', () => {
const res = googleApiErrorResponse(400, 'INVALID_ARGUMENT', 'Request contains an invalid argument.');
expect(res.statusCode).toBe(400);
const body = parse(res);
expect(body.reason).toBe('googleInvalidRequest');
expect(body.googleMessage).toBe('Request contains an invalid argument.');
});

test('FAILED_PRECONDITION (e.g. an archived course) is surfaced as 400', () => {
const res = googleApiErrorResponse(400, 'FAILED_PRECONDITION', 'CourseNotModifiable');
expect(res.statusCode).toBe(400);
expect(parse(res).googleStatus).toBe('FAILED_PRECONDITION');
});

test('other statuses stay a 502 but still carry the Google detail', () => {
const res = googleApiErrorResponse(429, 'RESOURCE_EXHAUSTED', 'Quota exceeded');
expect(res.statusCode).toBe(502);
const body = parse(res);
expect(body.reason).toBe('googleApiError');
expect(body.googleStatus).toBe('RESOURCE_EXHAUSTED');
expect(body.googleMessage).toBe('Quota exceeded');
});

test('omits empty Google detail fields', () => {
const body = parse(googleApiErrorResponse(500, '', ''));
expect(body.googleStatus).toBeUndefined();
expect(body.googleMessage).toBeUndefined();
expect(body.reason).toBe('googleApiError');
});
});
1 change: 1 addition & 0 deletions packages/scratch-gui/.prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ test/unit/lib/*
!test/unit/lib/classroom-assignment-utils.test.js
!test/unit/lib/classroom-download-utils.test.js
!test/unit/lib/classroom-group-utils.test.js
!test/unit/lib/google-classroom-auth.test.js
!test/unit/lib/classroom-retention.test.js
!test/unit/lib/shared-assignment-taxonomy.test.js
!test/unit/lib/shared-author-profile.test.js
Expand Down
71 changes: 71 additions & 0 deletions packages/scratch-gui/src/containers/classroom-error-utils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,67 @@
/**
* Translate a Google Classroom proxy failure (`reason` starting with `google`)
* into a localized, actionable message. Returns null when the error did not
* come from the Google Classroom proxy so the caller can fall through to the
* regular classroom branches.
* @param {object} intl - react-intl intl object
* @param {Error} err - Error from a classroom API call
* @returns {?string} Localized message, or null when not a Google failure.
*/
const translateGoogleClassroomError = (intl, err) => {
// `body.reason` comes from the API proxy; `googleReason` is set client-side
// (e.g. the teacher unticked a scope on the Google consent screen).
const body = (err && err.body) || {};
const reason = body.reason || (err && err.googleReason);
if (typeof reason !== 'string' || !reason.startsWith('google')) return null;

if (reason === 'googleTokenExpired') {
return intl.formatMessage({
defaultMessage:
'Your Google authorization has expired. Please try again and allow access to Google Classroom.',
description: 'Error when the Google Classroom access token is no longer valid',
id: 'gui.classroom.error.googleTokenExpired',
});
}
if (reason === 'googlePermissionDenied') {
return intl.formatMessage({
defaultMessage:
'Smalruby is not allowed to post to this Google Classroom class. Sign in again and allow ' +
'every requested permission, and check that you are a teacher of the class.',
description: 'Error when Google Classroom denies the post (missing scope or not a teacher)',
id: 'gui.classroom.error.googlePermissionDenied',
});
}
if (reason === 'googleCourseNotFound') {
return intl.formatMessage({
defaultMessage:
'The linked Google Classroom class could not be found. It may have been deleted, or the ' +
'Google account you signed in with may not be a teacher of that class.',
description: 'Error when the linked Google Classroom course is missing or inaccessible',
id: 'gui.classroom.error.googleCourseNotFound',
});
}

// googleInvalidRequest / googleApiError: Google rejected the request for a
// reason we cannot map. Show its own status and message so the teacher can
// report something actionable.
const detail = [body.googleStatus, body.googleMessage].filter(Boolean).join(': ');
if (detail) {
return intl.formatMessage(
{
defaultMessage: 'Could not post to Google Classroom. (Google: {detail})',
description: 'Error when Google Classroom rejects the post, with the Google-provided detail',
id: 'gui.classroom.error.googleApiFailedDetail',
},
{ detail },
);
}
return intl.formatMessage({
defaultMessage: 'Could not post to Google Classroom. Please wait a moment and try again.',
description: 'Error when posting to Google Classroom fails for an unknown reason',
id: 'gui.classroom.error.googleApiFailed',
});
};

/**
* Translate known API error messages to localized user-friendly messages.
* @param {object} intl - react-intl intl object
Expand Down Expand Up @@ -26,6 +90,13 @@ const translateError = (intl, err, context = 'general') => {
);
}

// Google Classroom proxy failures. Checked before the generic HTTP branches
// because their statuses overlap (a 401 here is an expired *Google* token,
// not an expired classroom session) and because the raw server message
// ("Google Classroom API error") is useless to a teacher (issue #1175).
const googleMessage = translateGoogleClassroomError(intl, err);
if (googleMessage) return googleMessage;

if (context === 'join' && (status === 404 || msg.includes('Invalid join code'))) {
return intl.formatMessage({
defaultMessage: 'Could not join the classroom. Please check the join code and try again.',
Expand Down
Loading
Loading